feat(startup): add configurable game root
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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.).
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <directory>` 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 [-- <userargs>]`.
|
||||
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 <directory>` 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 <install>` 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 <directory>` — 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 <NAME>` — 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 <pixels>` / `--window-height <pixels>` — override only the physical windowed client
|
||||
|
||||
132
engine/Age.Engine.Tests/GameRootSelectionTests.cs
Normal file
132
engine/Age.Engine.Tests/GameRootSelectionTests.cs
Normal file
@@ -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<ArgumentException>(
|
||||
() => 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<ArgumentException>(() => 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<ArgumentException>(() => GameRootSelection.Resolve(
|
||||
[], Path.Combine(executableDirectory, "OpenMaidEngine"), currentDirectory));
|
||||
|
||||
Assert.Contains(executableDirectory, error.Message);
|
||||
Assert.Contains(currentDirectory, error.Message);
|
||||
Assert.Contains("--game-root <directory>", 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
99
engine/Age.Engine/Sys4/GameRootSelection.cs
Normal file
99
engine/Age.Engine/Sys4/GameRootSelection.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace Age.Engine.Sys4;
|
||||
|
||||
public enum GameRootSource
|
||||
{
|
||||
LaunchArgument,
|
||||
ExecutableDirectory,
|
||||
CurrentDirectory,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the installed game's root independently of repository/development paths.
|
||||
/// A valid root is anchored by the game's authoritative SYS4INI catalog.
|
||||
/// </summary>
|
||||
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<string> 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} <directory>");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<int, int>(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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,7 +6,7 @@ be relocated without editing any tool -- there are no hard-coded drive paths.
|
||||
Workspace layout (siblings under the workspace root):
|
||||
|
||||
<workspace>/ 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)
|
||||
|
||||
Reference in New Issue
Block a user