Compare commits

...
Author SHA1 Message Date
Mike Klaas 7dab1e6219 Test fix web build mouse 2026-04-19 20:28:31 -07:00
Mike Klaas dcba8882bf Commandline .dat file viewer (#373) 2026-04-19 15:29:18 -07:00
Mike Klaas 62c2621fef Implement HOOK_ONDEATH (#384)
* Implement HOOK_ONDEATH

Test both combat death and script-triggered death

* move hp and DEAD flag above hook call
2026-04-19 14:24:51 -07:00
12 changed files with 569 additions and 11 deletions
+1
View File
@@ -10,6 +10,7 @@
*.userosscache
*.sln.docstates
.DS_Store
.local-tools/
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
+22
View File
@@ -446,6 +446,28 @@ target_include_directories(${EXECUTABLE_NAME} PRIVATE ${ZLIB_INCLUDE_DIRS})
target_link_libraries(${EXECUTABLE_NAME} ${SDL2_LIBRARIES})
target_include_directories(${EXECUTABLE_NAME} PRIVATE ${SDL2_INCLUDE_DIRS})
add_executable(fallout2-dat
"tools/dat_tool.cc"
"src/dfile.cc"
"src/platform_compat.cc"
)
if(APPLE)
set_target_properties(fallout2-dat PROPERTIES
XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO"
XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO"
)
endif()
target_link_libraries(fallout2-dat
fpattern_windows::fpattern_windows
${ZLIB_LIBRARIES}
${SDL2_LIBRARIES}
)
target_include_directories(fallout2-dat PRIVATE "src")
target_include_directories(fallout2-dat PRIVATE ${ZLIB_INCLUDE_DIRS})
target_include_directories(fallout2-dat PRIVATE ${SDL2_INCLUDE_DIRS})
if(APPLE)
if(IOS)
install(TARGETS ${EXECUTABLE_NAME} DESTINATION "Payload")
-3
View File
@@ -119,9 +119,6 @@
"base"
],
"generator": "Xcode",
"cacheVariables": {
"CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY": ""
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
+20
View File
@@ -2,6 +2,26 @@
This file is currently a shell to be filled in over time. For now, it only captures project-specific maintenance notes that are easy to miss.
## `.dat` CLI Tool
There is a small command-line archive tool in this repo for inspecting Fallout `.dat` files using the same reader implementation as the game.
Build it with `make TARGET=fallout2-dat`.
The executable is written to your selected `BUILD_DIR`, so the default path is `out/build/local-debug-arm64/fallout2-dat` on this macOS setup, but other platforms or custom build directories will differ.
The current tool is read-only. Available commands:
1. `./<BUILD_DIR>/fallout2-dat <archive.dat> list [pattern]`
2. `./<BUILD_DIR>/fallout2-dat <archive.dat> info [pattern]`
3. `./<BUILD_DIR>/fallout2-dat <archive.dat> extract [--lower] <output-dir> [pattern]`
4. `./<BUILD_DIR>/fallout2-dat <archive.dat> cat <entry>`
Use `--lower` with `extract` when you want every extracted file and directory name forced to lowercase.
For example, from `/Applications/Fallout2Codex` you can run:
`/Users/klaas/game/fallout2-ce/out/build/local-debug-arm64/fallout2-dat master.dat extract --lower /tmp/fallout2-dat-lower data\\*`
## Updating SDL
SDL is pinned for native builds in `third_party/sdl2/CMakeLists.txt`. Right now, Android also relies on checked-in Java bindings in `os/android/app/src/main/java/org/libsdl/app`, and those bindings must match the SDL version fetched by CMake.
+1 -1
View File
@@ -82,7 +82,7 @@ See [`https://sfall-team.github.io/sfall/`](https://sfall-team.github.io/sfall/)
| DeathAnim1 | `HOOK_DEATHANIM1` | đźš« | Use DEATHANIM2 instead |
| DeathAnim2 | `HOOK_DEATHANIM2` | âś… | - |
| CombatDamage | `HOOK_COMBATDAMAGE` | âś… | - |
| OnDeath | `HOOK_ONDEATH` | đźš« | - |
| OnDeath | `HOOK_ONDEATH` | âś… | - |
| FindTarget | `HOOK_FINDTARGET` | đźš« | (maybe) |
| UseObjOn | `HOOK_USEOBJON` | âś… | - |
| UseObj | `HOOK_USEOBJ` | âś… | CE notes an sfall-matching inconsistency around return code `2` behavior between interface contexts. |
+52
View File
@@ -0,0 +1,52 @@
#include "sfall.h"
#include "dik.h"
#include "define_lite.h"
#define TEST_KEY (DIK_J)
procedure ondeath_handler begin
variable
dead_critter := get_sfall_arg_at(0),
critter_name := "<null>",
critter_pid := -1;
if (dead_critter) then begin
critter_name := obj_name(dead_critter);
critter_pid := obj_pid(dead_critter);
end
display_msg(string_format2("ondeath critter=%s pid=%d", critter_name, critter_pid));
end
procedure keypress_handler begin
variable
pressed := get_sfall_arg_at(0),
key := get_sfall_arg_at(1),
target;
if (not pressed) then return;
if (key != TEST_KEY) then return;
target := obj_under_cursor(true, false);
if (target == 0) then begin
display_msg("ondeath test: no critter under cursor");
return;
end
if (target == dude_obj) then begin
display_msg("ondeath test: refusing to kill dude");
return;
end
display_msg(string_format1("ondeath test: killing %s", obj_name(target)));
critter_dmg(target, 9999, DMG_BYPASS_ARMOR);
end
procedure start begin
if (not game_loaded) then return;
display_msg("ondeath manual test ready: hover a critter and press J");
register_hook_proc(HOOK_KEYPRESS, keypress_handler);
register_hook_proc(HOOK_ONDEATH, ondeath_handler);
end
+1
View File
@@ -4938,6 +4938,7 @@ static void _damage_object(Object* a1, int damage, bool animated, int a4, Object
}
partyMemberRemove(a1);
scriptHooks_OnDeath(a1);
}
}
+5 -3
View File
@@ -25,6 +25,7 @@
#include "random.h"
#include "reaction.h"
#include "scripts.h"
#include "sfall_script_hooks.h"
#include "skill.h"
#include "stat.h"
#include "tile.h"
@@ -825,7 +826,11 @@ void critterKill(Object* critter, int anim, bool refreshRect)
int elevation = critter->elevation;
critter->data.critter.hp = 0;
critter->data.critter.combat.results |= DAM_DEAD;
partyMemberRemove(critter);
scriptHooks_OnDeath(critter);
// NOTE: Original code uses goto to jump out from nested conditions below.
bool shouldChangeFid = false;
@@ -893,9 +898,6 @@ void critterKill(Object* critter, int anim, bool refreshRect)
_obj_turn_off_light(critter, &tempRect);
rectUnion(&updatedRect, &tempRect, &updatedRect);
critter->data.critter.hp = 0;
critter->data.critter.combat.results |= DAM_DEAD;
if (critter->sid != -1) {
scriptRemove(critter->sid);
critter->sid = -1;
-4
View File
@@ -60,10 +60,6 @@ bool mouseDeviceInitMode()
return SDL_SetRelativeMouseMode(SDL_TRUE) == 0;
}
if (SDL_SetRelativeMouseMode(SDL_FALSE) != 0) {
return false;
}
mouseRelativeMode = false;
mouseDeviceRefreshWindowMapping();
return true;
+10
View File
@@ -164,6 +164,16 @@ void scriptHooks_GameModeChange(int exit, int previousGameMode)
ScriptHookCall(HOOK_GAMEMODECHANGE, 0, { exit, previousGameMode }).call();
}
/*
Runs immediately after a critter dies for any reason.
Critter arg0 - The critter that just died
*/
void scriptHooks_OnDeath(Object* critter)
{
ScriptHookCall(HOOK_ONDEATH, 0, { critter }).call();
}
/*
Runs before and after each turn in combat (for both PC and NPC).
+1
View File
@@ -257,6 +257,7 @@ void scriptHooksReset();
void scriptHooksExit();
void scriptHooks_GameModeChange(int exit, int previousGameMode);
void scriptHooks_OnDeath(Object* critter);
bool scriptHooks_InventoryMove(HookInventoryMoveType actionType, Object* item, Object* targetItem);
bool scriptHooks_CombatTurnStart(Object* critter, bool reloadedDuringCombat);
bool scriptHooks_CombatTurnEnd(Object* critter, int turnResult, bool reloadedDuringCombat);
+456
View File
@@ -0,0 +1,456 @@
#include "dfile.h"
#include "platform_compat.h"
#include <algorithm>
#include <cerrno>
#include <cctype>
#include <fstream>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#endif
namespace fallout {
namespace {
struct Options {
std::string archivePath;
std::string command;
std::vector<std::string> args;
bool lowerExtractedPaths = false;
};
std::string normalizeDatPath(std::string path)
{
for (char& ch : path) {
if (ch == '/') {
ch = '\\';
}
}
return path;
}
std::string datPathToNativePath(std::string_view path)
{
std::string value(path);
for (char& ch : value) {
if (ch == '\\') {
ch = '/';
}
}
return value;
}
std::string toLowerAscii(std::string value)
{
for (char& ch : value) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return value;
}
bool isAbsoluteOutputPath(const std::string& path)
{
if (path.empty()) {
return false;
}
if (path[0] == '/' || path[0] == '\\') {
return true;
}
return path.size() >= 2 && path[1] == ':';
}
bool isSafeRelativeOutputPath(const std::string& path)
{
if (path.empty() || isAbsoluteOutputPath(path)) {
return false;
}
size_t start = 0;
while (start < path.size()) {
size_t end = path.find('/', start);
if (end == std::string::npos) {
end = path.size();
}
std::string_view component(path.data() + start, end - start);
if (component == "..") {
return false;
}
start = end + 1;
}
return true;
}
std::string joinNativePath(const std::string& basePath, const std::string& relativePath)
{
if (basePath.empty()) {
return relativePath;
}
if (basePath.back() == '/' || basePath.back() == '\\') {
return basePath + relativePath;
}
return basePath + "/" + relativePath;
}
bool ensureDirectoriesForFile(const std::string& filePath)
{
size_t separator = filePath.find_last_of("/\\");
if (separator == std::string::npos) {
return true;
}
std::string directoryPath = filePath.substr(0, separator);
if (directoryPath.empty()) {
return true;
}
size_t start = 0;
if (directoryPath.size() >= 2 && directoryPath[1] == ':') {
start = 2;
if (directoryPath.size() >= 3 && (directoryPath[2] == '/' || directoryPath[2] == '\\')) {
start = 3;
}
} else if (directoryPath[0] == '/' || directoryPath[0] == '\\') {
start = 1;
}
while (start <= directoryPath.size()) {
size_t end = directoryPath.find_first_of("/\\", start);
std::string partialPath = directoryPath.substr(0, end);
if (!partialPath.empty() && compat_mkdir(partialPath.c_str()) != 0 && errno != EEXIST) {
return false;
}
if (end == std::string::npos) {
break;
}
start = end + 1;
}
return true;
}
void printUsage(std::ostream& stream)
{
stream
<< "Usage:\n"
<< " fallout2-dat <archive.dat> list [pattern]\n"
<< " fallout2-dat <archive.dat> info [pattern]\n"
<< " fallout2-dat <archive.dat> extract [--lower] <output-dir> [pattern]\n"
<< " fallout2-dat <archive.dat> cat <entry>\n"
<< "\n"
<< "Notes:\n"
<< " - This tool is currently read-only.\n"
<< " - Patterns use the same Windows-style wildcard matching as the game.\n"
<< " - Archive paths are case-insensitive and should use backslashes internally.\n";
}
bool parseOptions(int argc, char** argv, Options* options)
{
if (argc < 3) {
return false;
}
options->archivePath = argv[1];
options->command = argv[2];
options->args.assign(argv + 3, argv + argc);
if (options->command == "extract") {
auto lowerIt = std::find(options->args.begin(), options->args.end(), "--lower");
if (lowerIt != options->args.end()) {
options->lowerExtractedPaths = true;
options->args.erase(lowerIt);
}
}
return true;
}
bool extractEntry(DBase* dbase, const DBaseEntry& entry, const std::string& outputDir, bool lowerExtractedPaths)
{
std::string relativePath = datPathToNativePath(entry.path);
if (lowerExtractedPaths) {
relativePath = toLowerAscii(std::move(relativePath));
}
if (!isSafeRelativeOutputPath(relativePath)) {
std::cerr << "Refusing to extract invalid path: " << entry.path << "\n";
return false;
}
std::string destination = joinNativePath(outputDir, relativePath);
if (!ensureDirectoriesForFile(destination)) {
std::cerr << "Failed to create output directory for: " << destination << "\n";
return false;
}
DFile* stream = dfileOpen(dbase, entry.path, "rb");
if (stream == nullptr) {
std::cerr << "Failed to open entry: " << entry.path << "\n";
return false;
}
std::ofstream output(destination, std::ios::binary);
if (!output.is_open()) {
std::cerr << "Failed to create output file: " << destination << "\n";
dfileClose(stream);
return false;
}
std::vector<char> buffer(64 * 1024);
long remaining = dfileGetSize(stream);
while (remaining > 0) {
size_t chunkSize = static_cast<size_t>(std::min<long>(remaining, buffer.size()));
size_t bytesRead = dfileRead(buffer.data(), 1, chunkSize, stream);
if (bytesRead == 0) {
std::cerr << "Failed while reading entry: " << entry.path << "\n";
dfileClose(stream);
return false;
}
output.write(buffer.data(), static_cast<std::streamsize>(bytesRead));
if (!output) {
std::cerr << "Failed while writing output file: " << destination << "\n";
dfileClose(stream);
return false;
}
remaining -= static_cast<long>(bytesRead);
}
if (dfileClose(stream) != 0) {
std::cerr << "Failed to close entry stream cleanly: " << entry.path << "\n";
return false;
}
std::cout << entry.path << " -> " << destination << "\n";
return true;
}
int listCommand(DBase* dbase, const std::string& pattern)
{
DFileFindData findData;
if (!dbaseFindFirstEntry(dbase, &findData, pattern.c_str())) {
return 1;
}
do {
std::cout << findData.fileName << "\n";
} while (dbaseFindNextEntry(dbase, &findData));
return 0;
}
int infoCommand(DBase* dbase, const std::vector<std::string>& args)
{
if (args.empty()) {
long long compressedBytes = 0;
long long uncompressedBytes = 0;
int compressedEntries = 0;
for (int index = 0; index < dbase->entriesLength; index++) {
const DBaseEntry& entry = dbase->entries[index];
compressedBytes += entry.dataSize;
uncompressedBytes += entry.uncompressedSize;
if (entry.compressed == 1) {
compressedEntries++;
}
}
std::cout
<< "archive: " << dbase->path << "\n"
<< "entries: " << dbase->entriesLength << "\n"
<< "data_offset: " << dbase->dataOffset << "\n"
<< "compressed_entries: " << compressedEntries << "\n"
<< "stored_bytes: " << compressedBytes << "\n"
<< "uncompressed_bytes: " << uncompressedBytes << "\n";
return 0;
}
std::string pattern = normalizeDatPath(args[0]);
DFileFindData findData;
if (!dbaseFindFirstEntry(dbase, &findData, pattern.c_str())) {
std::cerr << "No entries matched pattern: " << pattern << "\n";
return 1;
}
do {
const DBaseEntry* entry = &dbase->entries[findData.index];
std::cout
<< entry->path
<< "\tcompressed=" << static_cast<int>(entry->compressed)
<< "\tstored=" << entry->dataSize
<< "\tuncompressed=" << entry->uncompressedSize
<< "\toffset=" << entry->dataOffset
<< "\n";
} while (dbaseFindNextEntry(dbase, &findData));
return 0;
}
int extractCommand(DBase* dbase, const std::vector<std::string>& args, bool lowerExtractedPaths)
{
if (args.empty()) {
std::cerr << "extract requires an output directory\n";
return 1;
}
std::string outputDir = args[0];
std::string pattern = "*";
if (args.size() >= 2) {
pattern = normalizeDatPath(args[1]);
}
if (compat_mkdir(outputDir.c_str()) != 0 && errno != EEXIST) {
std::cerr << "Failed to create output directory: " << outputDir << "\n";
return 1;
}
DFileFindData findData;
if (!dbaseFindFirstEntry(dbase, &findData, pattern.c_str())) {
std::cerr << "No entries matched pattern: " << pattern << "\n";
return 1;
}
int extracted = 0;
do {
const DBaseEntry& entry = dbase->entries[findData.index];
if (!extractEntry(dbase, entry, outputDir, lowerExtractedPaths)) {
return 1;
}
extracted++;
} while (dbaseFindNextEntry(dbase, &findData));
std::cout << "Extracted " << extracted << " entr";
std::cout << (extracted == 1 ? "y" : "ies") << "\n";
return 0;
}
int catCommand(DBase* dbase, const std::vector<std::string>& args)
{
if (args.size() != 1) {
std::cerr << "cat requires exactly one archive entry path\n";
return 1;
}
std::string entryPath = normalizeDatPath(args[0]);
DFile* stream = dfileOpen(dbase, entryPath.c_str(), "rb");
if (stream == nullptr) {
std::cerr << "Entry not found: " << entryPath << "\n";
return 1;
}
#ifdef _WIN32
if (_setmode(_fileno(stdout), _O_BINARY) == -1) {
dfileClose(stream);
std::cerr << "Failed to switch stdout to binary mode\n";
return 1;
}
#endif
std::vector<char> buffer(64 * 1024);
long remaining = dfileGetSize(stream);
while (remaining > 0) {
size_t chunkSize = static_cast<size_t>(std::min<long>(remaining, buffer.size()));
size_t bytesRead = dfileRead(buffer.data(), 1, chunkSize, stream);
if (bytesRead == 0) {
dfileClose(stream);
std::cerr << "Failed while reading entry: " << entryPath << "\n";
return 1;
}
if (bytesRead != chunkSize) {
dfileClose(stream);
std::cerr << "Short read while reading entry: " << entryPath << "\n";
return 1;
}
remaining -= static_cast<long>(bytesRead);
std::cout.write(buffer.data(), static_cast<std::streamsize>(bytesRead));
if (!std::cout) {
dfileClose(stream);
std::cerr << "Failed while writing to stdout\n";
break;
}
}
if (!std::cout) {
return 1;
}
if (dfileClose(stream) != 0) {
std::cerr << "Failed to close entry stream cleanly: " << entryPath << "\n";
return 1;
}
return 0;
}
int run(const Options& options)
{
DBase* dbase = dbaseOpen(options.archivePath.c_str());
if (dbase == nullptr) {
std::cerr << "Failed to open archive: " << options.archivePath << "\n";
return 1;
}
int rc = 0;
if (options.command == "list") {
std::string pattern = "*";
if (!options.args.empty()) {
pattern = normalizeDatPath(options.args[0]);
}
rc = listCommand(dbase, pattern);
} else if (options.command == "info") {
rc = infoCommand(dbase, options.args);
} else if (options.command == "extract") {
rc = extractCommand(dbase, options.args, options.lowerExtractedPaths);
} else if (options.command == "cat") {
rc = catCommand(dbase, options.args);
} else {
std::cerr << "Unknown command: " << options.command << "\n";
printUsage(std::cerr);
rc = 1;
}
dbaseClose(dbase);
return rc;
}
} // namespace
} // namespace fallout
int main(int argc, char** argv)
{
fallout::Options options;
if (!fallout::parseOptions(argc, argv, &options)) {
fallout::printUsage(std::cerr);
return 1;
}
try {
return fallout::run(options);
} catch (const std::exception& e) {
std::cerr << "Unhandled error: " << e.what() << "\n";
return 1;
}
}