diff --git a/.gitignore b/.gitignore index 64b1914..af0b5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # age-reimpl repo. NOTE: the repo root IS age-reimpl/. The original game install -# (姫狩りダンジョンマイスター/) and the extracted ALF data (extracted/) are SIBLINGS +# (Himegari_Game/) and the extracted ALF data (extracted/) are SIBLINGS # of this directory, outside the repo entirely — they can never be committed. # Derived / regenerable corpora (rebuild via tools/extract_phase2.py, opcodes_build.py, etc.). diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 4470c83..f7c7db0 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -11,7 +11,7 @@ Workspace root: `S:\Game Hacking\Eushully\Himegari\` ``` S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ -├── 姫狩りダンジョンマイスター/ ← SOURCE — pristine game install (read-only) +├── Himegari_Game/ ← SOURCE — pristine game install (read-only) │ │ Never edit, move, or add to this folder. It holds ORIGINALS ONLY. │ ├── AGE.EXE, AGERC.DLL, *.dll shipped engine (packed). Stays intact and │ │ runnable in place — Frida launches it if needed. @@ -101,8 +101,9 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ └── manifest.json, opcode-coverage.md (opcode-coverage.md GENERATED from opcodes.toml) │ ├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests) - │ ├── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store, - │ script provider, AGF/LZSS and Windows CUR decoders, and resource facade + │ ├── Age.Engine/Sys4/ runtime game-root selection, catalog parser, loose-first bounded + │ ALF asset store, script provider, AGF/LZSS and Windows CUR decoders, + │ and resource facade │ └── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB, │ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec, │ and profile-owned state @@ -119,7 +120,7 @@ page ordinals to their authoritative script offsets for `tools/locate_page.py`. ## Conventions -- **Three-way separation.** `姫狩りダンジョンマイスター/` = untouched originals; `extracted/` = +- **Three-way separation.** `Himegari_Game/` = untouched originals; `extracted/` = game-derived data (regenerable, game-side); `age-reimpl/` = everything we authored. The first two are consumed, never modified. - **Tools never hard-code paths.** `tools/paths.py` derives `GAME_DIR`, `EXTRACTED`, `DATA1`, diff --git a/docs/himegari-port-reference.md b/docs/himegari-port-reference.md index 577866d..5b39780 100644 --- a/docs/himegari-port-reference.md +++ b/docs/himegari-port-reference.md @@ -2,8 +2,8 @@ Working reference for porting *姫狩りダンジョンマイスター* (Eushully, 2009) to Godot. -Source install: `C:\Program Files (x86)\Eushully\姫狩りダンジョンマイスター\` -Workspace: `S:\Game Hacking\Eushully\Himegari\` — three siblings: `姫狩りダンジョンマイスター\` (pristine game), `extracted\` (extracted ALF data), `age-reimpl\` (our work). See `docs/PROJECT-STRUCTURE.md`. +Source install: `S:\Game Hacking\Eushully\Himegari\Himegari_Game\` +Workspace: `S:\Game Hacking\Eushully\Himegari\` — three siblings: `Himegari_Game\` (pristine game), `extracted\` (extracted ALF data), `age-reimpl\` (our work). See `docs/PROJECT-STRUCTURE.md`. --- @@ -91,10 +91,10 @@ loop in `AGE.EXE`, which is the Ghidra task below. Graphics conversion (DATA2/5 save-format work remain deferred. ### Immediate (no tools needed beyond what's on disk) -1. ~~**Relocate the `Output\` tree**~~ **DONE** — workspace now at `S:\Game Hacking\Eushully\Himegari\姫狩りダンジョンマイスター\`. +1. ~~**Relocate the `Output\` tree**~~ **DONE** — pristine install now at `S:\Game Hacking\Eushully\Himegari\Himegari_Game\`. 2. **Convert remaining AGFs** in DATA2 (985 files) and DATA5 (210 files) with `AGF2BMP2AGF.exe`. *(Deferred — graphics not needed yet.)* The 3-file DATA1 gap is `CHAPTER.AGF`, `LOGO.AGF`, `TEST.AGF`. 3. ~~**Inventory the script files**~~ **DONE** — see [script-inventory.md](script-inventory.md). Key findings: all 481 scripts share magic `SYS4422 `; 49 loose root-dir script `.BIN` files shadow DATA1 copies (plus two root-only engine BINs; use overrides as authoritative); heavy game logic (damage calc, dungeon loop, battle flow) lives in bytecode, favoring a VM re-implementation in Godot. -1. ~~**Relocate the `Output\` tree**~~ **DONE** — workspace now at `S:\Game Hacking\Eushully\Himegari\姫狩りダンジョンマイスター\`. +1. ~~**Relocate the `Output\` tree**~~ **DONE** — pristine install now at `S:\Game Hacking\Eushully\Himegari\Himegari_Game\`. 2. **Convert remaining AGFs** in DATA2 (985 files) and DATA5 (210 files) with `AGF2BMP2AGF.exe`. *(Deferred — graphics not needed yet.)* The 3-file DATA1 gap is `CHAPTER.AGF`, `LOGO.AGF`, `TEST.AGF`. 3. ~~**Inventory the script files**~~ **DONE** — see [script-inventory.md](script-inventory.md). Key findings: all 481 scripts share magic `SYS4422 `; 49 loose root-dir script `.BIN` files shadow DATA1 copies (plus two root-only engine BINs; use overrides as authoritative); heavy game logic (damage calc, dungeon loop, battle flow) lives in bytecode, favoring a VM re-implementation in Godot. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 06b89c2..1ac12db 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -3569,6 +3569,33 @@ and an overridden `1200x700` request while continuing to report `logical-canvas= width exits during startup with the expected diagnostic. Headless's minimized `64x64` backend remains non-authoritative for visible client geometry. +### Runtime game-root selection result (implemented 2026-07-28) + +Godot no longer obtains installed game data from the development-only `Paths.GameDir` workspace +assumption. `GameRootSelection` establishes one normalized read-only root before loading SYS4INI: +`--game-root ` has highest precedence, followed by the running executable's directory and +then the current working directory. Candidates are accepted only when `SYS4INI.BIN` is present. +Relative overrides resolve against the working directory, repeated options use the last value, and an +invalid explicit override fails without silently selecting a different install. On Unix the frontend +uses inherited shell `PWD` as the launch working directory because Godot can change the process directory +to its project before managed startup. + +The selected root supplies the base catalog, loose overrides, append catalogs, and ALF archives through +one injected `Sys4AssetStore`. Normal execution, the debug-scene catalog, and threaded self-test reuse +that same catalog/store rather than re-entering repository path discovery. The local ignored +`run-godot.ps1` development convenience passes one directly configured `$gameRoot` value because editor +runs are hosted by the Godot executable; changing that assignment is the local cross-game testing seam. +An exported executable placed beside the original game data can use the executable-directory default. +This is the command-line boundary a future multi-profile launcher can populate with an absolute install +path. + +Eight focused cases cover explicit and relative selection, override precedence and duplicates, +executable-directory defaulting, working-directory fallback, missing values, invalid explicit roots, +and unresolved diagnostics. The complete engine suite passes **463/463**, the Godot project builds with +zero warnings, and the threaded headless self-test reports the selected `launch-argument` game root +before `SELFTEST OK`. Export packaging of generated opcode/profile metadata remains a separate +distribution step; this slice removes the installed-game hardcoding only. + ## ADV requested-face selection implemented (2026-07-28) Opcode `0x1a5` is no longer a presentation no-op. The VM retains its requested face in diff --git a/docs/platform-portability.md b/docs/platform-portability.md index a8b4c8c..c90dde3 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -35,7 +35,7 @@ or replaced before claiming portable exports. | ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows `MS 明朝`/`MS ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback. AGE actually uses a display information DC, weight-700 `LOGFONTA`, `GGO_GRAY4_BITMAP`, and its own integer glyph/outline compositor; the current `Label` backend substitutes FreeType embolden and a Godot outline | On the reference Windows install, regular advances and direct SC0000 placement/bounds agree, but the behaviorally different provisional bold path has 45% fewer bright pixels and much softer edge coverage. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | Deferred until gameplay settles: implement the Phase-E decoded mask/metrics/compositor backlog in `docs/remake-architecture-and-roadmap.md`, with a shipped GDI reference backend on Windows and an explicitly defined portable rasterizer/substitution policy | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Save/profile storage | Managed `DirectoryNativeDatStore` under Godot `user://SAVE`; native S3SD/S4SD/S3RT files and 24-bit BMP thumbnails | No Win32 path API at runtime; port saves remain isolated from the original installation | Validate replace/flush, case, permissions, and interrupted-write behavior on each export target | -| Install/repository discovery | `engine/Age.Engine/Sys4/Paths.cs` finds `age-reimpl` above `AppContext.BaseDirectory` and assumes the current workspace sibling layout | Suitable for development, not packaged exports on any OS | Replace runtime discovery with a user-selected game root/profile; retain repository paths only for developer tools/tests | +| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS | | Archive parity oracle | One integration test launches `bin/BinExtractALF.exe` | Windows-only test helper, not a shipped runtime dependency | Skip/replace on non-Windows CI; runtime ALF/AAI readers do not depend on it | | Native RE tools | Frida/Ghidra helpers target the original `AGE.EXE`; supporting utilities include Windows executables and Windows command conventions | Development/research only | Keep separate from export requirements; document platform prerequisites per tool | | Python workflow | Operating guide uses Windows `py -3.11` invocation | Developer workflow only | Add equivalent `python3` instructions if non-Windows development becomes active | diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index ac85ddc..6342aa2 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -649,6 +649,14 @@ the toolchain at the new game's archives, re-run extraction, rebuild its global **This is the core payoff of the VM approach:** the *engine* cost amortizes across all AGE games; only content-mapping recurs — far less than re-coding each game's logic bespoke. +The first runtime install-selection boundary is now in place. Godot receives one normalized game root, +defaulting to the executable directory with a current-working-directory fallback and accepting an +explicit `--game-root` override; the selected root is injected into the generic SYS4 catalog/store +instead of inferred from the Himegari repository layout. A future multi-profile launcher can therefore +own install discovery and pass the chosen profile's absolute root through the same stable argument. +Generated engine/profile metadata still needs an export-owned bundle before this constitutes complete +drop-in packaging; invocation details live in `tools-reference.md`. + #### Opcode ABI registry must be independent of per-game coverage A cursory Kamidori boot probe on 2026-07-20 validated much of this boundary: its own `SYS4INI.BIN` and diff --git a/docs/superpowers/plans/2026-07-06-a1-csharp-vm.md b/docs/superpowers/plans/2026-07-06-a1-csharp-vm.md index 7cd5cc3..556475d 100644 --- a/docs/superpowers/plans/2026-07-06-a1-csharp-vm.md +++ b/docs/superpowers/plans/2026-07-06-a1-csharp-vm.md @@ -162,7 +162,7 @@ public static class Paths public static string Workspace => Directory.GetParent(Repo)!.FullName; public static string Extracted => Path.Combine(Workspace, "extracted"); public static string Data1 => Path.Combine(Extracted, "DATA1"); - public static string GameDir => Path.Combine(Workspace, "姫狩りダンジョンマイスター"); + public static string GameDir => Path.Combine(Workspace, "Himegari_Game"); public static string Build => Path.Combine(Repo, "build"); public static string OpcodesJson => Path.Combine(Build, "opcodes.json"); diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 6d77171..45b977a 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -298,11 +298,24 @@ at 2.5M lines). All observe-only → parity preserved; all on `run`/`play`/`swee **Godot frontend** (`S:/Godot/Godot_v4.7…`; project = `godot/`). Toolchain: `godot --headless --path godot --import` → `dotnet build godot/Himegari.csproj` → `godot [--headless] --path godot [-- ]`. -Plays the real bytecode with call-script execution on (subroutines run live). A no-argument launch starts -the persistent `SYSTEM4.BIN` root and reaches TITLE naturally. The local `run-godot.ps1`/`.cmd` launchers -make that route explicit with `--scene SYSTEM4` and pass neither `--boot` nor SC0000 seeds. `--headless` can't -render texture ops (no GPU context) — run windowed for real scenes. On VM termination the console prints -the exact halt reason and step count before the generic on-screen `-end-` marker. User args (after `--`): +Plays the real bytecode with call-script execution on (subroutines run live). The game root is selected +before catalog loading: `--game-root ` wins, otherwise the directory containing the running +executable is tried, then the current working directory. A candidate must contain `SYS4INI.BIN`; invalid +explicit roots and unresolved defaults stop startup with the searched locations. Relative overrides are +resolved against the current working directory, and repeated overrides use the last value. On Unix, +Godot preserves the shell launch directory through inherited `PWD` because editor/project startup may +change the process directory before managed code runs. + +With the exported executable placed in an AGE install, a no-argument launch therefore uses that install +and starts the persistent `SYSTEM4.BIN` root naturally. Direct development runs are hosted by the Godot +editor executable, so pass `--game-root ` after Godot's `--` separator; the local +`run-godot.ps1`/`.cmd` launchers pass the local script's directly configured `$gameRoot` value. Edit that +one assignment when testing another installed game. Those launchers make the natural route explicit with +`--scene SYSTEM4` and pass neither `--boot` nor SC0000 seeds. Examples below focus on their +feature-specific arguments and assume the game root is already selected this way. `--headless` can't +render texture ops (no GPU context) — run windowed for real scenes. +On VM termination the console prints the exact halt reason and step count before the generic on-screen +`-end-` marker. User args (after `--`): The local launchers also accept **`-StartupDiagnostics`** for a native-faithful cold boot through TITLE, Game Start, and SC0000. The switch overrides `run-godot.cmd`'s usual `-NativeDebugMenu` convenience for @@ -316,6 +329,9 @@ wait, optionally press F3, and close the window normally. Until shutdown cancell ADV input release, normal window close can append one trailing shutdown-only record to `page-map.jsonl`; use the last `input-wait` event in `timeline.jsonl` as the authoritative manual stop coordinate. +- `--game-root ` — select the read-only AGE installation supplying `SYS4INI.BIN`, loose + overrides, AAI append catalogs, and ALF archives. Absolute paths are launcher-friendly; relative paths + are current-working-directory-relative. This is the stable handoff for future game-profile launchers. - `--scene ` — override the default `SYSTEM4` root with a direct diagnostic scene, e.g. `--scene SC0240` (executes 29 nested subroutines). - `--selftest` — headless; runs a **synthesized** scene through the thread/suspend/`CallDeferred` plumbing and asserts it matches a live headless run (full handling; no vm0/frozen golden). Exits. - `--window-width ` / `--window-height ` — override only the physical windowed client diff --git a/engine/Age.Engine.Tests/GameRootSelectionTests.cs b/engine/Age.Engine.Tests/GameRootSelectionTests.cs new file mode 100644 index 0000000..1062f4f --- /dev/null +++ b/engine/Age.Engine.Tests/GameRootSelectionTests.cs @@ -0,0 +1,132 @@ +using Age.Engine.Sys4; + +public sealed class GameRootSelectionTests +{ + [Fact] + public void ExplicitRelativeOverrideWinsAndResolvesAgainstCurrentDirectory() + { + using var tree = new TempTree(); + string executableDirectory = tree.CreateDirectory("exe", withCatalog: true); + string currentDirectory = tree.CreateDirectory("working", withCatalog: true); + string selectedDirectory = tree.CreateDirectory("selected", withCatalog: true); + + GameRootSelection selection = GameRootSelection.Resolve( + ["--scene", "SYSTEM4", "--game-root", Path.Combine("..", "selected")], + Path.Combine(executableDirectory, "OpenMaidEngine.exe"), + currentDirectory); + + Assert.Equal(selectedDirectory, selection.Root); + Assert.Equal(GameRootSource.LaunchArgument, selection.Source); + Assert.Equal("launch-argument", selection.SourceName); + } + + [Fact] + public void DuplicateOverrideUsesLastDirectory() + { + using var tree = new TempTree(); + string currentDirectory = tree.CreateDirectory("working"); + tree.CreateDirectory("first", withCatalog: true); + string second = tree.CreateDirectory("second", withCatalog: true); + + GameRootSelection selection = GameRootSelection.Resolve( + ["--game-root", Path.Combine("..", "first"), + "--game-root", Path.Combine("..", "second")], + null, + currentDirectory); + + Assert.Equal(second, selection.Root); + } + + [Fact] + public void DefaultsToExecutableDirectory() + { + using var tree = new TempTree(); + string executableDirectory = tree.CreateDirectory("exe", withCatalog: true); + string currentDirectory = tree.CreateDirectory("working", withCatalog: true); + + GameRootSelection selection = GameRootSelection.Resolve( + [], Path.Combine(executableDirectory, "OpenMaidEngine.exe"), currentDirectory); + + Assert.Equal(executableDirectory, selection.Root); + Assert.Equal(GameRootSource.ExecutableDirectory, selection.Source); + } + + [Fact] + public void FallsBackToCurrentDirectory() + { + using var tree = new TempTree(); + string executableDirectory = tree.CreateDirectory("exe"); + string currentDirectory = tree.CreateDirectory("working", withCatalog: true); + + GameRootSelection selection = GameRootSelection.Resolve( + [], Path.Combine(executableDirectory, "OpenMaidEngine"), currentDirectory); + + Assert.Equal(currentDirectory, selection.Root); + Assert.Equal(GameRootSource.CurrentDirectory, selection.Source); + } + + [Theory] + [InlineData("--game-root")] + [InlineData("--game-root", "--scene", "SYSTEM4")] + public void MissingOverridePathIsRejected(params string[] arguments) + { + using var tree = new TempTree(); + + var error = Assert.Throws( + () => GameRootSelection.Resolve(arguments, null, tree.Root)); + + Assert.Contains("--game-root requires a directory path", error.Message); + } + + [Fact] + public void ExplicitDirectoryWithoutCatalogIsRejectedWithoutFallback() + { + using var tree = new TempTree(); + string executableDirectory = tree.CreateDirectory("exe", withCatalog: true); + string currentDirectory = tree.CreateDirectory("working"); + tree.CreateDirectory("invalid"); + + var error = Assert.Throws(() => GameRootSelection.Resolve( + ["--game-root", Path.Combine("..", "invalid")], + Path.Combine(executableDirectory, "OpenMaidEngine.exe"), + currentDirectory)); + + Assert.Contains("does not contain SYS4INI.BIN", error.Message); + } + + [Fact] + public void MissingDefaultsReportSearchedLocationsAndOverride() + { + using var tree = new TempTree(); + string executableDirectory = tree.CreateDirectory("exe"); + string currentDirectory = tree.CreateDirectory("working"); + + var error = Assert.Throws(() => GameRootSelection.Resolve( + [], Path.Combine(executableDirectory, "OpenMaidEngine"), currentDirectory)); + + Assert.Contains(executableDirectory, error.Message); + Assert.Contains(currentDirectory, error.Message); + Assert.Contains("--game-root ", error.Message); + } + + private sealed class TempTree : IDisposable + { + public string Root { get; } = Path.Combine( + Path.GetTempPath(), "age-game-root-tests", Guid.NewGuid().ToString("N")); + + public TempTree() => Directory.CreateDirectory(Root); + + public string CreateDirectory(string name, bool withCatalog = false) + { + string path = Path.Combine(Root, name); + Directory.CreateDirectory(path); + if (withCatalog) File.WriteAllBytes(Path.Combine(path, "SYS4INI.BIN"), []); + return Path.GetFullPath(path); + } + + public void Dispose() + { + if (Directory.Exists(Root)) Directory.Delete(Root, recursive: true); + } + } +} diff --git a/engine/Age.Engine/Sys4/GameRootSelection.cs b/engine/Age.Engine/Sys4/GameRootSelection.cs new file mode 100644 index 0000000..0eb989a --- /dev/null +++ b/engine/Age.Engine/Sys4/GameRootSelection.cs @@ -0,0 +1,99 @@ +namespace Age.Engine.Sys4; + +public enum GameRootSource +{ + LaunchArgument, + ExecutableDirectory, + CurrentDirectory, +} + +/// +/// Resolves the installed game's root independently of repository/development paths. +/// A valid root is anchored by the game's authoritative SYS4INI catalog. +/// +public readonly record struct GameRootSelection(string Root, GameRootSource Source) +{ + public const string OptionName = "--game-root"; + + public string Sys4IniPath => Path.Combine(Root, "SYS4INI.BIN"); + + public string SourceName => Source switch + { + GameRootSource.LaunchArgument => "launch-argument", + GameRootSource.ExecutableDirectory => "executable-directory", + GameRootSource.CurrentDirectory => "current-directory", + _ => throw new InvalidOperationException($"unknown game-root source: {Source}"), + }; + + public static GameRootSelection Resolve( + IReadOnlyList arguments, string? executablePath, string currentDirectory) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(currentDirectory); + + string cwd = Path.GetFullPath(currentDirectory); + string? overridePath = null; + for (int index = 0; index < arguments.Count; index++) + { + if (arguments[index] != OptionName) continue; + if (index + 1 >= arguments.Count || arguments[index + 1].StartsWith("--", StringComparison.Ordinal)) + throw new ArgumentException($"{OptionName} requires a directory path"); + overridePath = arguments[++index]; + } + + if (overridePath != null) + { + string root = Path.GetFullPath(overridePath, cwd); + RequireCatalog(root, $"{OptionName} '{overridePath}'"); + return new GameRootSelection(root, GameRootSource.LaunchArgument); + } + + var candidates = new List<(string Root, GameRootSource Source)>(); + string? executableDirectory = TryGetExecutableDirectory(executablePath, cwd); + if (executableDirectory != null) + candidates.Add((executableDirectory, GameRootSource.ExecutableDirectory)); + if (!candidates.Any(candidate => SamePath(candidate.Root, cwd))) + candidates.Add((cwd, GameRootSource.CurrentDirectory)); + + foreach ((string root, GameRootSource source) in candidates) + if (File.Exists(Path.Combine(root, "SYS4INI.BIN"))) + return new GameRootSelection(root, source); + + string searched = string.Join(", ", candidates.Select(candidate => $"'{candidate.Root}'")); + throw new ArgumentException( + $"game root not found: SYS4INI.BIN was not present in {searched}; " + + $"pass {OptionName} "); + } + + private static string? TryGetExecutableDirectory(string? executablePath, string cwd) + { + if (string.IsNullOrWhiteSpace(executablePath)) return null; + try + { + return Path.GetDirectoryName(Path.GetFullPath(executablePath, cwd)); + } + catch (Exception error) when ( + error is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + } + + private static void RequireCatalog(string root, string description) + { + if (!File.Exists(Path.Combine(root, "SYS4INI.BIN"))) + throw new ArgumentException( + $"{description} does not contain SYS4INI.BIN (resolved to '{root}')"); + } + + private static bool SamePath(string left, string right) + { + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals( + Path.TrimEndingDirectorySeparator(left), + Path.TrimEndingDirectorySeparator(right), + comparison); + } +} diff --git a/engine/Age.Engine/Sys4/Paths.cs b/engine/Age.Engine/Sys4/Paths.cs index bfe19b3..5585578 100644 --- a/engine/Age.Engine/Sys4/Paths.cs +++ b/engine/Age.Engine/Sys4/Paths.cs @@ -5,7 +5,7 @@ public static class Paths public static string Workspace => Directory.GetParent(Repo)!.FullName; public static string Extracted => Path.Combine(Workspace, "extracted"); public static string Data1 => Path.Combine(Extracted, "DATA1"); - public static string GameDir => Path.Combine(Workspace, "姫狩りダンジョンマイスター"); + public static string GameDir => Path.Combine(Workspace, "Himegari_Game"); public static string Build => Path.Combine(Repo, "build"); public static string OpcodesJson => Path.Combine(Build, "opcodes.json"); public static string AssetSectionsJson => Path.Combine(Build, "asset-sections.json"); diff --git a/godot/Main.cs b/godot/Main.cs index 6cf5b57..9bb3aac 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -23,6 +23,8 @@ public partial class Main : Godot.Control private int _screenWidth = Sys4LogicalCanvas.DefaultWidth; private int _screenHeight = Sys4LogicalCanvas.DefaultHeight; private WindowLaunchOptions _windowOptions; + private Sys4AssetCatalog _catalog = null!; + private IAssetStore _assetStore = null!; private TextureRect _screenView = null!; // shows the composited screen backbuffer private Image _screen = null!; // SYS4INI-sized immediate-mode canvas private ImageTexture _screenTex = null!; @@ -110,9 +112,38 @@ public partial class Main : Godot.Control public override void _Ready() { var userArgs = OS.GetCmdlineUserArgs(); + GameRootSelection gameRoot; + Sys4AssetCatalog catalog; + try + { + string workingDirectory = System.IO.Directory.GetCurrentDirectory(); + if (!System.OperatingSystem.IsWindows()) + { + // Godot can chdir to the project before managed startup. Unix shells preserve the launch + // directory in PWD, which keeps terminal-launched exports faithful to their caller. + string? inheritedWorkingDirectory = + System.Environment.GetEnvironmentVariable("PWD"); + if (!string.IsNullOrWhiteSpace(inheritedWorkingDirectory) + && System.IO.Directory.Exists(inheritedWorkingDirectory)) + workingDirectory = inheritedWorkingDirectory; + } + gameRoot = GameRootSelection.Resolve( + userArgs, OS.GetExecutablePath(), workingDirectory); + catalog = Sys4AssetCatalog.Load(gameRoot.Sys4IniPath); + } + catch (System.Exception error) when ( + error is System.ArgumentException or System.IO.IOException or System.UnauthorizedAccessException) + { + GD.PushError($"[startup] {error.Message}"); + GetTree().Quit(2); + return; + } + _catalog = catalog; + _assetStore = new Sys4AssetStore(catalog, gameRoot.Root, gameRoot.Root); + GD.Print($"[profile] game root={gameRoot.Root} source={gameRoot.SourceName}"); + // Resolve the selected game's logical canvas before any presentation allocation. The same catalog // instance is reused for scripts and assets later in startup. - var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var logicalCanvas = catalog.LogicalCanvas; try { @@ -298,7 +329,7 @@ public partial class Main : Godot.Control catalog.AppendPacks.Select(pair => new KeyValuePair(pair.Key, pair.Value.RawSlots.Count))); trackedAssetStore = new CatalogTrackingAssetStore( - new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir), + _assetStore, entry => sharedProfile.MarkCatalogResourceOpened(entry.PackedId)); scripts = new Sys4ScriptProvider(table, catalog, trackedAssetStore); script = scripts.RequireByName(scene + ".BIN"); @@ -314,7 +345,7 @@ public partial class Main : Godot.Control _locatorHud.Visible = _locatorHudVisible; var resources = scripts != null ? new ResourceMap(scripts.Catalog, trackedAssetStore) - : ResourceMap.Load(); + : new ResourceMap(catalog, _assetStore); _host = new GodotAdvHost( this, resources, scene, _clock, _locator, logicalCanvas, _timeline, synchronizeExplicitPresentation: !_selftest) @@ -2080,7 +2111,7 @@ public partial class Main : Godot.Control var actual = _host.Captured.ConvertAll(c => c.Offset); bool ok = actual.Count == expected.Count; for (int i = 0; ok && i < actual.Count; i++) ok = actual[i] == expected[i]; - var debugEntries = DebugSceneCatalog.Build(Sys4AssetCatalog.Load(Paths.Sys4Ini)); + var debugEntries = DebugSceneCatalog.Build(_catalog); bool launcherOk = debugEntries.Any(entry => entry.Name == "DEBUG.BIN" && entry.Launchable) && debugEntries.Select(entry => entry.PackedId).Distinct().Count() == debugEntries.Count; var launcherSmoke = new DebugSceneLauncher(); @@ -2096,7 +2127,7 @@ public partial class Main : Godot.Control new InputEventKey { PhysicalKeycode = Key.Up }, out int upVk) && upVk == 0x26 && Win32VirtualKeyTranslator.TryTranslate( new InputEventKey { PhysicalKeycode = Key.Ctrl }, out int ctrlVk) && ctrlVk == 0x11; - var selftestResources = ResourceMap.Load(); + var selftestResources = new ResourceMap(_catalog, _assetStore); AudioPayload glowSfx = selftestResources.ReadAudio(selftestResources.ResolveSoundEffect(0x28)!); byte[] glowGodotWav = RiffWaveSanitizer.RemoveInfoMetadata(glowSfx.Bytes); bool cp932WavMetadataOk = glowGodotWav.Length == 688_336 diff --git a/tools/frida/capture_global_writes.py b/tools/frida/capture_global_writes.py index 7510080..8317590 100644 --- a/tools/frida/capture_global_writes.py +++ b/tools/frida/capture_global_writes.py @@ -30,7 +30,7 @@ from collections import OrderedDict from pathlib import Path REPO = Path(__file__).resolve().parents[2] -GAME_DIR = REPO.parent / "姫狩りダンジョンマイスター" +GAME_DIR = REPO.parent / "Himegari_Game" AGE_EXE = GAME_DIR / "AGE.EXE" RAW = REPO / "build" / "global-writes.jsonl" SNAP = REPO / "build" / "scene-entry-state.json" diff --git a/tools/paths.py b/tools/paths.py index 5e27f02..99b3a36 100644 --- a/tools/paths.py +++ b/tools/paths.py @@ -6,7 +6,7 @@ be relocated without editing any tool -- there are no hard-coded drive paths. Workspace layout (siblings under the workspace root): / e.g. S:\\Game Hacking\\Eushully\\Himegari - 姫狩りダンジョンマイスター/ pristine game install (AGE.EXE, *.ALF, + Himegari_Game/ pristine game install (AGE.EXE, *.ALF, loose *.BIN patch-overrides, DLLs) extracted/ extracted ALF data: DATA1 .. DATA5 age-reimpl/ our work (this repo) @@ -20,7 +20,7 @@ from pathlib import Path REPO = Path(__file__).resolve().parent.parent # age-reimpl/ WORKSPACE = REPO.parent # workspace root -GAME_DIR = WORKSPACE / "姫狩りダンジョンマイスター" # pristine game install +GAME_DIR = WORKSPACE / "Himegari_Game" # pristine game install EXTRACTED = WORKSPACE / "extracted" # extracted ALF archives DATA1 = EXTRACTED / "DATA1" # the .BIN script corpus BUILD = REPO / "build" # derived corpora (regenerable)