From 3a06920ef14299e86b86d39304afdefe7794b032 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Wed, 14 Oct 2026 23:51:50 -0500 Subject: [PATCH 01/14] docs: record pre-split baseline and migration plan --- ROADMAP.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c7b5d38..713c494 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -238,6 +238,21 @@ The stage files the original ships but never plays, after stage 20. See the deci **Done when** every bonus stage that ships has a solution that clears it in the port, and the switch's rules have tests. +## M5: Split into three repos (started 2026.10.14) + +This repo goes back to being a faithful preservation port of the original 20 stages. The rules move to a shared engine, and everything the original never had moves to a standalone Community Edition that needs no Nocturne. + +- **Three peer repos** in one local folder: `puzzle-boy-engine` (the headless rules, `PuzzleBoy.Rules`), `puzzle-boy` (this repo) and `puzzle-boy-community`. Both games use the engine as a pinned git submodule at `external/puzzle-boy-engine`, with project references to its source. No NuGet feed, and no floating `main`: an engine change is committed there first, then each consumer bumps its pin. +- **Ownership.** The original 20-file order stays here. The 23-file bonus order, the switch key and stage skipping move to Community, which can import the bonus stages from the player's own install. `Board.Switch()` stays in the engine; this port no longer calls it. The M4 research and its record above stay as they are. +- **Baseline** (observed, before any change): HEAD `410fc89`, tags `v0.1.0` and `v0.2.0`, local tag `pre-split-baseline` on `410fc89`. Build clean, 164 tests pass, format clean, and the no-game-data check and its positive control pass. `PlayOrder.Files` is `2, 4, 32, 8, 38, 36, 37, 48, 42, 49, 40, 20, 22, 13, 21, 24, 14, 10, 25, 26`; `PlayOrder.Bonus` is `1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44`. `re/` is local and untouched. +- The split happens on the `split/three-repos` branch. Nothing is pushed and no GitHub repo is created until the owner says so. + +- [ ] Extract the rules into `puzzle-boy-engine`, with their tests. +- [ ] Depend on the pinned engine and restore the original 20-stage campaign: no bonus stages, no switch key, no stage skipping. +- [ ] Update the docs to tell the preservation port apart from Community Edition. + +**Done when** this repo builds and tests against the pinned engine, plays exactly the 20 original stages, and passes every CI gate including the no-game-data check. + ## Later - A stage editor. The original ships a developer editor (`pbEdit`). From e22b2a3bdd03222ecd21162f513f8fdf321e8946 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:21:20 -0500 Subject: [PATCH 02/14] fix(ci): let the game-data guard skip submodules --- scripts/check-no-game-data.sh | 2 ++ scripts/check-no-game-data.test.sh | 3 +++ 2 files changed, 5 insertions(+) diff --git a/scripts/check-no-game-data.sh b/scripts/check-no-game-data.sh index 2df8205..27c392f 100755 --- a/scripts/check-no-game-data.sh +++ b/scripts/check-no-game-data.sh @@ -12,6 +12,8 @@ report() { # Asset formats, recognized by their first bytes rather than by name. while IFS= read -r -d '' file; do + # A submodule is listed as its directory; its own repo runs this check on its files. + [ -f "$file" ] || continue magic=$(head -c 12 "$file" | od -An -tx1 | tr -d ' \n') case "$magic" in 556e6974794653*) report "$file: Unity asset bundle" ;; diff --git a/scripts/check-no-game-data.test.sh b/scripts/check-no-game-data.test.sh index d2df6e9..a971ec6 100755 --- a/scripts/check-no-game-data.test.sh +++ b/scripts/check-no-game-data.test.sh @@ -13,6 +13,9 @@ expect() { mkdir -p "$repo/$(dirname "$path")" printf '%b' "$content" > "$repo/$path" git -C "$repo" add -A + # Every scratch repo also holds a submodule, as the real one does; the check must skip it, not stop on it. + mkdir -p "$repo/external/engine" + git -C "$repo" update-index --add --cacheinfo "160000,$(printf '%040d' 1),external/engine" if "$check" "$repo" > /dev/null; then actual=pass; else actual=fail; fi rm -rf "$repo" if [ "$actual" = "$outcome" ]; then From cb796b13e167ab10ae503a2b27f9ea420b000ab7 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:22:20 -0500 Subject: [PATCH 03/14] refactor: depend on shared puzzle engine The rules now come from puzzle-boy-engine, pinned as a submodule at external/puzzle-boy-engine (99eaf06). src/PuzzleBoy.Rules and the nine rule-only test files are deleted here; they live in the engine with the same tests. The original order moves to OriginalStageOrder in the game. Behavior is unchanged: the bonus list sits in GameAssets until the next commit restores the original twenty stages. CI checks out submodules and fails early with a clear message when the engine is missing. --- .github/workflows/ci.yml | 10 + .github/workflows/release.yml | 10 + .gitmodules | 3 + PuzzleBoy.slnx | 4 +- external/puzzle-boy-engine | 1 + src/PuzzleBoy.Rules/Board.cs | 398 ------------------ src/PuzzleBoy.Rules/CellKind.cs | 48 --- src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj | 7 - src/PuzzleBoy.Rules/Stage.cs | 110 ----- src/PuzzleBoy/GameAssets.cs | 5 +- .../OriginalStageOrder.cs} | 10 +- src/PuzzleBoy/PuzzleBoy.csproj | 2 +- tests/PuzzleBoy.Tests/CellTests.cs | 30 -- tests/PuzzleBoy.Tests/GoalTests.cs | 54 --- tests/PuzzleBoy.Tests/LastMoveTests.cs | 82 ---- tests/PuzzleBoy.Tests/PlayOrderTests.cs | 8 +- tests/PuzzleBoy.Tests/PushingTests.cs | 152 ------- tests/PuzzleBoy.Tests/PuzzleBoy.Tests.csproj | 2 +- tests/PuzzleBoy.Tests/StageTests.cs | 86 ---- tests/PuzzleBoy.Tests/SwitchTests.cs | 92 ---- tests/PuzzleBoy.Tests/SwivelTests.cs | 277 ------------ tests/PuzzleBoy.Tests/UndoTests.cs | 178 -------- tests/PuzzleBoy.Tests/WalkingTests.cs | 51 --- 23 files changed, 38 insertions(+), 1582 deletions(-) create mode 100644 .gitmodules create mode 160000 external/puzzle-boy-engine delete mode 100644 src/PuzzleBoy.Rules/Board.cs delete mode 100644 src/PuzzleBoy.Rules/CellKind.cs delete mode 100644 src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj delete mode 100644 src/PuzzleBoy.Rules/Stage.cs rename src/{PuzzleBoy.Rules/PlayOrder.cs => PuzzleBoy/OriginalStageOrder.cs} (62%) delete mode 100644 tests/PuzzleBoy.Tests/CellTests.cs delete mode 100644 tests/PuzzleBoy.Tests/GoalTests.cs delete mode 100644 tests/PuzzleBoy.Tests/LastMoveTests.cs delete mode 100644 tests/PuzzleBoy.Tests/PushingTests.cs delete mode 100644 tests/PuzzleBoy.Tests/StageTests.cs delete mode 100644 tests/PuzzleBoy.Tests/SwitchTests.cs delete mode 100644 tests/PuzzleBoy.Tests/SwivelTests.cs delete mode 100644 tests/PuzzleBoy.Tests/UndoTests.cs delete mode 100644 tests/PuzzleBoy.Tests/WalkingTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96de891..7646900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,16 @@ jobs: DOTNET_CLI_TELEMETRY_OPTOUT: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + # The rules come from the engine submodule. Without it, restore fails with a puzzling missing-project error. + - name: Engine submodule + run: | + if [ ! -f external/puzzle-boy-engine/src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj ]; then + echo "::error::external/puzzle-boy-engine is empty. Check out with submodules, or run: git submodule update --init" + exit 1 + fi - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05b0794..a84a6f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,16 @@ jobs: DOTNET_CLI_TELEMETRY_OPTOUT: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + # The rules come from the engine submodule. Without it, restore fails with a puzzling missing-project error. + - name: Engine submodule + run: | + if [ ! -f external/puzzle-boy-engine/src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj ]; then + echo "::error::external/puzzle-boy-engine is empty. Check out with submodules, or run: git submodule update --init" + exit 1 + fi - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6a37a53 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/puzzle-boy-engine"] + path = external/puzzle-boy-engine + url = https://github.com/hazeliscoding/puzzle-boy-engine.git diff --git a/PuzzleBoy.slnx b/PuzzleBoy.slnx index 8a9b8c1..f93bcb9 100644 --- a/PuzzleBoy.slnx +++ b/PuzzleBoy.slnx @@ -1,6 +1,8 @@ + + + - diff --git a/external/puzzle-boy-engine b/external/puzzle-boy-engine new file mode 160000 index 0000000..99eaf06 --- /dev/null +++ b/external/puzzle-boy-engine @@ -0,0 +1 @@ +Subproject commit 99eaf06246540591a91802385e8fc7aeb7126467 diff --git a/src/PuzzleBoy.Rules/Board.cs b/src/PuzzleBoy.Rules/Board.cs deleted file mode 100644 index 4ce9f69..0000000 --- a/src/PuzzleBoy.Rules/Board.cs +++ /dev/null @@ -1,398 +0,0 @@ -namespace PuzzleBoy.Rules; - -/// A step on the stage grid, numbered the way the original numbers its directions. -public enum Direction -{ - /// One cell toward larger y. - PlusY, - - /// One cell toward larger x. - PlusX, - - /// One cell toward smaller y. - MinusY, - - /// One cell toward smaller x. - MinusX, -} - -/// Helpers for . -public static class Directions -{ - /// The (x, y) step one cell in . - public static (int X, int Y) Offset(this Direction direction) => direction switch - { - Direction.PlusY => (0, 1), - Direction.PlusX => (1, 0), - Direction.MinusY => (0, -1), - _ => (-1, 0), - }; -} - -/// What a step did. -public enum StepResult -{ - /// The character walked one cell. - Walked, - - /// The character pushed a block one cell and followed it. The block may have fallen into holes. - Pushed, - - /// The character turned a swivel a quarter turn and moved one or two cells. - Turned, - - /// Nothing changed. The original makes no distinction between a bump and a refused push or turn. - Refused, -} - -/// What a successful step moved, so the game can animate it; the board has already changed. -/// Walked, pushed or turned; never refused. -/// The index into of the character that moved. It may no longer be active. -/// The direction of the step. -/// How far the character moved: 1, or 2 for a swivel's two-cell step. -/// The pushed or turned group's id; 0 for a walk. -/// Where the group's cells are now; empty for a walk. After a fall these cells are plain floor. -/// The swivel's center, for a turn. -/// For a turn, -1 clockwise or +1 counterclockwise, with x right and y up; 0 otherwise. -/// True when a pushed block fell into the holes under it. -public sealed record Move( - StepResult Result, int Mover, Direction Direction, int Cells, int Group, - IReadOnlyList<(int X, int Y)> Landings, (int X, int Y) Center, int Turn, bool Fell); - -/// A character on the board. A character that reaches the goal while others are still out is hidden. -/// The stage file's slot, 0 to 3. -/// The column. -/// The row. -/// False once the character has reached the goal and left the board. -public readonly record struct Character(int Slot, int X, int Y, bool Visible); - -/// -/// A stage in play: the cells, the characters, the goal and the undo history. Steps apply the original's rules -/// (ROADMAP.md, "M0 findings") instantly; animation and input timing belong to the game. -/// -public sealed class Board -{ - private const int UndoLimit = 30; - private const ushort PlainWall = 0x2000; - - // When a block or swivel part leaves a cell, the cell keeps its hole flag and the unused bit 0x1000. - private const ushort Underneath = 0x1800; - - private readonly Stage _stage; - private readonly ushort[] _cells = new ushort[Stage.Width * Stage.Height]; - private readonly Character[] _characters; - private readonly List<(ushort[] Cells, Character[] Characters, int Active)> _history = []; - private int _active; - - /// Starts from its file layout, with the first character in control. - public Board(Stage stage) - { - _stage = stage; - _characters = new Character[stage.Characters.Count]; - Retry(); - } - - /// The raw cell value at (, ); see . - public ushort this[int x, int y] => _cells[Index(x, y)]; - - /// Every character, in slot order, including those that left at the goal. - public IReadOnlyList Characters => _characters; - - /// The character the player controls. - public Character Active => _characters[_active]; - - /// The index of in . - public int ActiveIndex => _active; - - /// The goal's position. - public (int X, int Y) Goal => _stage.Goal; - - /// True once the last character has reached the goal. A cleared board refuses every step. - public bool IsCleared { get; private set; } - - /// Whether a push or turn can be undone. Plain walking is never recorded. - public bool CanUndo => _history.Count > 0; - - /// What the last moved; null after a refused step, a switch, an undo or a retry. - public Move? LastMove { get; private set; } - - /// Moves the active character one step, pushing a block or turning a swivel if that's what it walks into. - public StepResult Step(Direction direction) - { - LastMove = null; - var me = Active; - if (IsCleared || (me.X, me.Y) == Goal) - { - return StepResult.Refused; - } - - var (dx, dy) = direction.Offset(); - var (x, y) = (me.X + dx, me.Y + dy); - if (!InGrid(x, y) || OtherCharacterAt(x, y) || (this[x, y] & Cell.HoleFlag) != 0) - { - return StepResult.Refused; - } - - switch (Cell.KindOf(this[x, y])) - { - case CellKind.Floor: - LastMove = new Move(StepResult.Walked, _active, direction, 1, 0, [], default, 0, false); - MoveActive(dx, dy, 1); - return StepResult.Walked; - case CellKind.Block: - return Push(direction, Cell.GroupOf(this[x, y])); - case CellKind.SwivelArm: - return Turn(direction, Cell.GroupOf(this[x, y])); - default: - return StepResult.Refused; - } - } - - /// Goes back to the moment before the last push or turn, discarding any walking since. Returns false when there's nothing to undo. - public bool Undo() - { - if (_history.Count == 0) - { - return false; - } - - var (cells, characters, active) = _history[^1]; - _history.RemoveAt(_history.Count - 1); - LastMove = null; - cells.CopyTo(_cells, 0); - characters.CopyTo(_characters, 0); - _active = active; - return true; - } - - /// - /// Hands control to the next visible character, in the order a goal arrival uses. The original has no switch; the - /// port adds it for the unused multi-character stages, which can't be finished without one (ROADMAP.md, M4). - /// It isn't recorded, so undo still returns control to the character that made the undone push or turn. - /// Returns false, changing nothing, on a cleared board or when only one character is left. - /// - public bool Switch() - { - LastMove = null; - if (IsCleared || _characters.Count(c => c.Visible) < 2) - { - return false; - } - - PassControl(); - return true; - } - - /// Restarts the stage from its file layout and wipes the undo history. - public void Retry() - { - for (var y = 0; y < Stage.Height; y++) - { - for (var x = 0; x < Stage.Width; x++) - { - _cells[Index(x, y)] = _stage[x, y]; - } - } - - for (var i = 0; i < _characters.Length; i++) - { - var (slot, x, y) = _stage.Characters[i]; - _characters[i] = new Character(slot, x, y, true); - } - - _active = 0; - IsCleared = false; - _history.Clear(); - LastMove = null; - } - - private StepResult Push(Direction direction, int group) - { - var (dx, dy) = direction.Offset(); - var parts = PartsOf(group); - foreach (var (x, y) in parts) - { - var (tx, ty) = (x + dx, y + dy); - if (!InGrid(tx, ty) || OtherCharacterAt(tx, ty) || !(this[tx, ty] >> 13 == 0 || IsPartOf(this[tx, ty], group))) - { - return StepResult.Refused; - } - } - - Record(); - var landings = parts.Select(p => (p.X + dx, p.Y + dy)).ToList(); - Shift(parts, landings); - - // A block falls only when every one of its cells is over a hole; the holes become plain floor. - var fell = landings.TrueForAll(l => (_cells[Index(l.Item1, l.Item2)] & Cell.HoleFlag) != 0); - if (fell) - { - foreach (var (x, y) in landings) - { - _cells[Index(x, y)] &= 0x1000; - } - } - - LastMove = new Move(StepResult.Pushed, _active, direction, 1, group, landings, default, 0, fell); - MoveActive(dx, dy, 1); - return StepResult.Pushed; - } - - private StepResult Turn(Direction direction, int group) - { - var parts = PartsOf(group); - var me = Active; - var center = parts.Find(p => Cell.KindOf(this[p.X, p.Y]) == CellKind.SwivelCenter); - if (!parts.Exists(p => Cell.KindOf(this[p.X, p.Y]) == CellKind.SwivelCenter) || me.X == center.X || me.Y == center.Y) - { - return StepResult.Refused; - } - - // The original picks the sense from which side of the center the character stands on; - // either way the pushed arm ends up on the side the push points to. - var turn = direction switch - { - Direction.PlusY => me.X < center.X ? -1 : 1, - Direction.PlusX => me.Y < center.Y ? 1 : -1, - Direction.MinusY => me.X < center.X ? 1 : -1, - _ => me.Y < center.Y ? -1 : 1, - }; - - var (dx, dy) = direction.Offset(); - var steps = IsPartAt(me.X + dy, me.Y + dx, group) || IsPartAt(me.X - dy, me.Y - dx, group) ? 2 : 1; - for (var i = 0; i <= steps; i++) - { - var (px, py) = (me.X + (i * dx), me.Y + (i * dy)); - if (!InGrid(px, py) || (this[px, py] & Cell.HoleFlag) != 0 || this[px, py] == PlainWall) - { - return StepResult.Refused; - } - } - - var landings = new List<(int, int)>(); - foreach (var (x, y) in parts) - { - var (ox, oy) = (x - center.X, y - center.Y); - var (rx, ry) = turn == -1 ? (oy, -ox) : (-oy, ox); - var land = (center.X + rx, center.Y + ry); - var sweep = (center.X + ox + rx, center.Y + oy + ry); - if (BlocksTurn(land, group) || BlocksTurn(sweep, group)) - { - return StepResult.Refused; - } - - landings.Add(land); - } - - Record(); - Shift(parts, landings); - LastMove = new Move(StepResult.Turned, _active, direction, steps, group, landings, center, turn, false); - MoveActive(dx, dy, steps); - return StepResult.Turned; - } - - private bool BlocksTurn((int X, int Y) cell, int group) - { - if (!InGrid(cell.X, cell.Y) || OtherCharacterAt(cell.X, cell.Y)) - { - return true; - } - - var value = this[cell.X, cell.Y]; - return value >> 13 == 1 || (value >> 13 >= 2 && Cell.GroupOf(value) != group); - } - - private void MoveActive(int dx, int dy, int steps) - { - var me = Active; - _characters[_active] = me with { X = me.X + (dx * steps), Y = me.Y + (dy * steps) }; - if ((Active.X, Active.Y) != Goal) - { - return; - } - - if (_characters.Count(c => c.Visible) < 2) - { - IsCleared = true; - _history.Clear(); - return; - } - - _characters[_active] = Active with { Visible = false }; - PassControl(); - } - - /// Moves control to the next visible character in ascending slot order, wrapping around. - private void PassControl() - { - do - { - _active = (_active + 1) % _characters.Length; - } - while (!_characters[_active].Visible); - } - - /// Moves a group's parts to , leaving each old cell's hole flag behind and keeping each new cell's. - private void Shift(List<(int X, int Y)> parts, List<(int, int)> landings) - { - var values = parts.Select(p => (ushort)(this[p.X, p.Y] & ~Underneath)).ToList(); - foreach (var (x, y) in parts) - { - _cells[Index(x, y)] &= Underneath; - } - - for (var i = 0; i < landings.Count; i++) - { - var (x, y) = landings[i]; - _cells[Index(x, y)] = (ushort)((_cells[Index(x, y)] & Underneath) | values[i]); - } - } - - private void Record() - { - _history.Add(((ushort[])_cells.Clone(), (Character[])_characters.Clone(), _active)); - if (_history.Count > UndoLimit) - { - _history.RemoveAt(0); - } - } - - /// Every block or swivel cell of . The original keys groups by id alone, so the cells needn't touch. - private List<(int X, int Y)> PartsOf(int group) - { - var parts = new List<(int X, int Y)>(); - for (var y = 0; y < Stage.Height; y++) - { - for (var x = 0; x < Stage.Width; x++) - { - if (IsPartOf(this[x, y], group)) - { - parts.Add((x, y)); - } - } - } - - return parts; - } - - private bool IsPartAt(int x, int y, int group) => InGrid(x, y) && IsPartOf(this[x, y], group); - - private static bool IsPartOf(ushort cell, int group) => cell >> 13 is >= 2 and <= 5 && Cell.GroupOf(cell) == group; - - private bool OtherCharacterAt(int x, int y) - { - for (var i = 0; i < _characters.Length; i++) - { - if (i != _active && _characters[i].Visible && _characters[i].X == x && _characters[i].Y == y) - { - return true; - } - } - - return false; - } - - private static bool InGrid(int x, int y) => x is >= 0 and < Stage.Width && y is >= 0 and < Stage.Height; - - private static int Index(int x, int y) => (y * Stage.Width) + x; -} diff --git a/src/PuzzleBoy.Rules/CellKind.cs b/src/PuzzleBoy.Rules/CellKind.cs deleted file mode 100644 index 67b42fb..0000000 --- a/src/PuzzleBoy.Rules/CellKind.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace PuzzleBoy.Rules; - -/// What occupies a cell of the stage grid. -public enum CellKind -{ - /// Open floor a character can walk on. - Floor, - - /// A fixed wall. The grid outside the play area is all wall. - Wall, - - /// A hole. Characters can't cross it until a block fills it. - Hole, - - /// Part of a pushable block. Cells with the same group form one block. - Block, - - /// The axis a swivel block turns around. - SwivelCenter, - - /// An arm of a swivel block. It turns with the center of the same group. - SwivelArm, -} - -/// Reads the raw 16-bit cell values of a . -public static class Cell -{ - /// The flag that marks a hole, under whatever else the cell holds. - public const ushort HoleFlag = 0x0800; - - /// - /// What a raw cell value holds, from its type in bits 13 to 15. A block or swivel part keeps its kind - /// when it rests on a hole; the hole flag beneath it is read separately. - /// - public static CellKind KindOf(ushort cell) => (cell >> 13) switch - { - 0 => (cell & HoleFlag) != 0 ? CellKind.Hole : CellKind.Floor, - // Type 2 is a block group's core cell. The original assigns it when a stage loads; stage files only use 3. - 2 or 3 => CellKind.Block, - 4 => CellKind.SwivelCenter, - 5 => CellKind.SwivelArm, - // Type 1 is a wall, and the original treats the unused types 6 and 7 as solid too. - _ => CellKind.Wall, - }; - - /// The block or swivel group a cell belongs to: its low 11 bits, below the hole flag. - public static int GroupOf(ushort cell) => cell & 0x07FF; -} diff --git a/src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj b/src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj deleted file mode 100644 index 2328974..0000000 --- a/src/PuzzleBoy.Rules/PuzzleBoy.Rules.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - PuzzleBoy.Rules - - - diff --git a/src/PuzzleBoy.Rules/Stage.cs b/src/PuzzleBoy.Rules/Stage.cs deleted file mode 100644 index d51a6de..0000000 --- a/src/PuzzleBoy.Rules/Stage.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Buffers.Binary; - -namespace PuzzleBoy.Rules; - -/// A stage as the original game stores it in its pbMapNN.map files. -public sealed class Stage -{ - /// Columns in every stage's grid. - public const int Width = 20; - - /// Rows in every stage's grid. - public const int Height = 20; - - /// The size of an undamaged stage file, in bytes. - public const int FileSize = 828; - - private const int Slots = 4; - private const int GoalOffset = 24; - private const int GridOffset = 28; - - private readonly ushort[] _cells; - - private Stage(IReadOnlyList<(int Slot, int X, int Y)> characters, (int X, int Y) goal, ushort[] cells) - { - Characters = characters; - Goal = goal; - _cells = cells; - } - - /// The characters' start positions, in slot order. Slots can be skipped. - public IReadOnlyList<(int Slot, int X, int Y)> Characters { get; } - - /// The goal's position: the spinning blue cube. - public (int X, int Y) Goal { get; } - - /// The raw cell value: kind flags in the high byte, block or swivel group in the low byte. - public ushort this[int x, int y] => _cells[(y * Width) + x]; - - /// Reads a stage file as it ships in the pazzleboy bundle. - /// The file's bytes. One longer than is taken to carry the CRLF damage and is repaired first. - /// - /// The bytes aren't a stage: the wrong size, no enabled character, a count that disagrees with the enable bytes, or a position off the grid. - /// - public static Stage Parse(ReadOnlySpan file) - { - if (file.Length > FileSize) - { - file = UndoCrlf(file); - } - - if (file.Length != FileSize) - { - throw new InvalidDataException($"A stage file is {FileSize} bytes, but this one is {file.Length}."); - } - - var characters = new List<(int Slot, int X, int Y)>(); - for (var slot = 0; slot < Slots; slot++) - { - // The enable byte decides, not the order: some stages use only slot 1, or slots 2 and 3. - if (file[4 + slot] != 0) - { - var (x, y) = ReadPosition(file, 8 + (slot * 4)); - characters.Add((slot, x, y)); - } - } - - var count = BinaryPrimitives.ReadInt32LittleEndian(file); - if (characters.Count == 0 || characters.Count != count) - { - throw new InvalidDataException($"The stage says it has {count} characters but enables {characters.Count}."); - } - - var cells = new ushort[Width * Height]; - for (var i = 0; i < cells.Length; i++) - { - cells[i] = BinaryPrimitives.ReadUInt16LittleEndian(file[(GridOffset + (i * 2))..]); - } - - return new Stage(characters, ReadPosition(file, GoalOffset), cells); - } - - private static (int X, int Y) ReadPosition(ReadOnlySpan file, int offset) - { - int x = BinaryPrimitives.ReadInt16LittleEndian(file[offset..]); - int y = BinaryPrimitives.ReadInt16LittleEndian(file[(offset + 2)..]); - if (x is < 0 or >= Width || y is < 0 or >= Height) - { - throw new InvalidDataException($"The position ({x}, {y}) at byte {offset} is outside the {Width}x{Height} grid."); - } - - return (x, y); - } - - // Six of the shipped stage files had every 0A byte rewritten as 0D 0A by a line-ending conversion. - private static byte[] UndoCrlf(ReadOnlySpan file) - { - var repaired = new List(file.Length); - for (var i = 0; i < file.Length; i++) - { - if (file[i] == 0x0D && i + 1 < file.Length && file[i + 1] == 0x0A) - { - continue; - } - - repaired.Add(file[i]); - } - - return [.. repaired]; - } -} diff --git a/src/PuzzleBoy/GameAssets.cs b/src/PuzzleBoy/GameAssets.cs index d713be4..95562e1 100644 --- a/src/PuzzleBoy/GameAssets.cs +++ b/src/PuzzleBoy/GameAssets.cs @@ -14,11 +14,14 @@ internal sealed record GameAssets( IReadOnlyList<(int File, Stage Stage)> Stages, TextureData Atlas, TextureData Background, IReadOnlyDictionary Sounds, CharacterData Character, UiSheets Ui, TextData Text) { + /// The M4 bonus files played after stage 20; removed in the next commit, which restores the original 20. + internal static IReadOnlyList Bonus { get; } = [1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44]; + /// Reads every asset, opening each bundle once; common is large and slow to unpack. public static GameAssets Read(GameInstall install) { var text = install.ReadTextAssets("pazzleboy"); - var stages = PlayOrder.Files.Concat(PlayOrder.Bonus).Select(file => (file, Stage.Parse(text[PlayOrder.AssetName(file)]))).ToList(); + var stages = OriginalStageOrder.Files.Concat(Bonus).Select(file => (file, Stage.Parse(text[OriginalStageOrder.AssetName(file)]))).ToList(); var common = install.ReadTextures("common", "BG_pzl", "tone2", "pzlUI01", "pzlUI_font01_ja", "talkUI01"); var english = install.ReadTextures("ui_tex_en", "pzlUI_font01_en", "buttonUI01_en_common"); return new GameAssets( diff --git a/src/PuzzleBoy.Rules/PlayOrder.cs b/src/PuzzleBoy/OriginalStageOrder.cs similarity index 62% rename from src/PuzzleBoy.Rules/PlayOrder.cs rename to src/PuzzleBoy/OriginalStageOrder.cs index 53c0cf0..0d74f92 100644 --- a/src/PuzzleBoy.Rules/PlayOrder.cs +++ b/src/PuzzleBoy/OriginalStageOrder.cs @@ -1,7 +1,7 @@ -namespace PuzzleBoy.Rules; +namespace PuzzleBoy; /// The stage files the original plays, in its order (ROADMAP.md, "M0 findings": stages and dialogue). -public static class PlayOrder +internal static class OriginalStageOrder { /// /// Level 1's 20 stages. The code lists file 00 as the single stage of levels 2 and 3, but play ends after @@ -10,12 +10,6 @@ public static class PlayOrder /// public static IReadOnlyList Files { get; } = [2, 4, 32, 8, 38, 36, 37, 48, 42, 49, 40, 20, 22, 13, 21, 24, 14, 10, 25, 26]; - /// - /// The unused files the port plays after stage 20 (ROADMAP.md, M4): only those with a solution that clears them in - /// the port, ordered by the pushes and turns it takes, file number breaking ties. - /// - public static IReadOnlyList Bonus { get; } = [1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44]; - /// The TextAsset name of stage file in the pazzleboy bundle. public static string AssetName(int file) => $"pbMap{file:D2}.map"; } diff --git a/src/PuzzleBoy/PuzzleBoy.csproj b/src/PuzzleBoy/PuzzleBoy.csproj index 19f1d8f..e1ca597 100644 --- a/src/PuzzleBoy/PuzzleBoy.csproj +++ b/src/PuzzleBoy/PuzzleBoy.csproj @@ -21,7 +21,7 @@ - + diff --git a/tests/PuzzleBoy.Tests/CellTests.cs b/tests/PuzzleBoy.Tests/CellTests.cs deleted file mode 100644 index 0ce6797..0000000 --- a/tests/PuzzleBoy.Tests/CellTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class CellTests -{ - [Theory] - [InlineData(0x0000, CellKind.Floor)] - [InlineData(0x1000, CellKind.Floor)] - [InlineData(0x0800, CellKind.Hole)] - [InlineData(0x2000, CellKind.Wall)] - [InlineData(0x2800, CellKind.Wall)] - [InlineData(0xC000, CellKind.Wall)] - [InlineData(0xE000, CellKind.Wall)] - [InlineData(0x4005, CellKind.Block)] - [InlineData(0x607D, CellKind.Block)] - [InlineData(0x8001, CellKind.SwivelCenter)] - [InlineData(0xA001, CellKind.SwivelArm)] - [InlineData(0xA802, CellKind.SwivelArm)] - public void KindOf_reads_the_type_from_bits_13_to_15(int cell, CellKind kind) => - Assert.Equal(kind, Cell.KindOf((ushort)cell)); - - [Theory] - [InlineData(0x607D, 0x7D)] - [InlineData(0x6123, 0x123)] - [InlineData(0xA802, 0x002)] - [InlineData(0x0FFF, 0x7FF)] - public void GroupOf_reads_the_low_11_bits_below_the_hole_flag(int cell, int group) => - Assert.Equal(group, Cell.GroupOf((ushort)cell)); -} diff --git a/tests/PuzzleBoy.Tests/GoalTests.cs b/tests/PuzzleBoy.Tests/GoalTests.cs deleted file mode 100644 index 76f077e..0000000 --- a/tests/PuzzleBoy.Tests/GoalTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class GoalTests -{ - [Fact] - public void The_last_character_reaching_the_goal_clears_the_stage() - { - const string Before = "1G"; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Walked, board.Step(Direction.PlusX)); - Assert.True(board.IsCleared); - Assert.Equal(StepResult.Refused, board.Step(Direction.MinusX)); - } - - [Fact] - public void Earlier_arrivals_leave_the_board_and_hand_control_to_the_next_character() - { - const string Before = "1G2"; - var board = Ascii.Board(Before); - - board.Step(Direction.PlusX); - - Assert.False(board.IsCleared); - Assert.Equal(1, board.Active.Slot); - Assert.Equal(".G2", Ascii.Draw(board, Before)); - - board.Step(Direction.MinusX); - - Assert.True(board.IsCleared); - } - - [Fact] - public void Control_skips_characters_that_already_reached_the_goal() - { - const string Before = """ - 2G3 - .1. - """; - var board = Ascii.Board(Before); - - board.Step(Direction.MinusY); - Assert.Equal(1, board.Active.Slot); - - board.Step(Direction.PlusX); - Assert.Equal(2, board.Active.Slot); - Assert.False(board.IsCleared); - - board.Step(Direction.MinusX); - Assert.True(board.IsCleared); - } -} diff --git a/tests/PuzzleBoy.Tests/LastMoveTests.cs b/tests/PuzzleBoy.Tests/LastMoveTests.cs deleted file mode 100644 index 6f77306..0000000 --- a/tests/PuzzleBoy.Tests/LastMoveTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class LastMoveTests -{ - [Fact] - public void A_walk_reports_the_mover_and_no_group() - { - var board = Ascii.Board("1.."); - - board.Step(Direction.PlusX); - - var move = board.LastMove!; - Assert.Equal((StepResult.Walked, 0, Direction.PlusX, 1, 0), (move.Result, move.Mover, move.Direction, move.Cells, move.Group)); - Assert.Empty(move.Landings); - } - - [Fact] - public void A_push_reports_where_the_block_landed() - { - var board = Ascii.Board(""" - 1a. - .a. - """); - - board.Step(Direction.PlusX); - - var move = board.LastMove!; - Assert.Equal((StepResult.Pushed, 1, false), (move.Result, move.Group, move.Fell)); - Assert.Equal([(2, 0), (2, 1)], move.Landings); - } - - [Fact] - public void A_push_into_holes_reports_the_fall() - { - var board = Ascii.Board("1ao.."); - - board.Step(Direction.PlusX); - - Assert.True(board.LastMove!.Fell); - Assert.Equal([(2, 0)], board.LastMove.Landings); - } - - [Fact] - public void A_turn_reports_the_center_and_the_sense_with_y_up() - { - // In the drawing y runs down, so this clockwise turn (x right, y up) looks counterclockwise here. - var board = Ascii.Board(""" - ..... - .1... - .RC.. - ..... - """); - - board.Step(Direction.PlusY); - - var move = board.LastMove!; - Assert.Equal((StepResult.Turned, (2, 2), -1, 1), (move.Result, move.Center, move.Turn, move.Cells)); - Assert.Contains((2, 3), move.Landings); - } - - [Fact] - public void A_refused_step_an_undo_and_a_retry_clear_it() - { - var board = Ascii.Board("1a.#"); - - board.Step(Direction.PlusX); - Assert.NotNull(board.LastMove); - board.Step(Direction.PlusX); - Assert.Null(board.LastMove); - - board.Step(Direction.MinusX); - board.Undo(); - Assert.Null(board.LastMove); - - board.Step(Direction.PlusX); - Assert.NotNull(board.LastMove); - board.Retry(); - Assert.Null(board.LastMove); - } -} diff --git a/tests/PuzzleBoy.Tests/PlayOrderTests.cs b/tests/PuzzleBoy.Tests/PlayOrderTests.cs index a5126d8..d38d129 100644 --- a/tests/PuzzleBoy.Tests/PlayOrderTests.cs +++ b/tests/PuzzleBoy.Tests/PlayOrderTests.cs @@ -1,5 +1,3 @@ -using PuzzleBoy.Rules; - namespace PuzzleBoy.Tests; public sealed class PlayOrderTests @@ -7,8 +5,8 @@ public sealed class PlayOrderTests [Fact] public void Bonus_stages_are_unused_files_each_played_once() { - Assert.Empty(PlayOrder.Bonus.Intersect(PlayOrder.Files)); - Assert.Equal(PlayOrder.Bonus.Count, PlayOrder.Bonus.Distinct().Count()); - Assert.All(PlayOrder.Bonus, file => Assert.InRange(file, 0, 49)); + Assert.Empty(GameAssets.Bonus.Intersect(OriginalStageOrder.Files)); + Assert.Equal(GameAssets.Bonus.Count, GameAssets.Bonus.Distinct().Count()); + Assert.All(GameAssets.Bonus, file => Assert.InRange(file, 0, 49)); } } diff --git a/tests/PuzzleBoy.Tests/PushingTests.cs b/tests/PuzzleBoy.Tests/PushingTests.cs deleted file mode 100644 index 514cb66..0000000 --- a/tests/PuzzleBoy.Tests/PushingTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class PushingTests -{ - [Fact] - public void A_push_moves_the_block_one_cell_and_the_character_follows() - { - const string Before = "1a."; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(".1a", Ascii.Draw(board, Before)); - } - - [Theory] - [InlineData("1a#")] - [InlineData("1ab.")] - [InlineData("1aR.")] - [InlineData("1a2.")] - public void A_wall_another_block_a_swivel_or_a_character_refuses_a_push(string before) - { - var board = Ascii.Board(before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusX)); - Assert.Equal(before, Ascii.Draw(board, before)); - } - - [Fact] - public void One_blocked_cell_refuses_the_whole_push() - { - const string Before = """ - 1a.. - .aa# - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusX)); - Assert.Equal(Before, Ascii.Draw(board, Before)); - } - - [Fact] - public void A_shaped_block_moves_into_its_own_cells() - { - const string Before = """ - 1a.. - .aa. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(""" - .1a. - ..aa - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void A_block_can_be_pushed_onto_the_goal_without_clearing_the_stage() - { - const string Before = "1aG"; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(".1a", Ascii.Draw(board, Before)); - Assert.False(board.IsCleared); - } - - [Fact] - public void Another_character_blocks_only_its_own_cell_not_the_mirrored_one() - { - // The original swaps x and y in this check, so a character at (0, 2) would block (2, 0). - // No stage it plays has two characters; the port checks the real cell. - const string Before = """ - 1a. - ... - 2.. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - } - - [Fact] - public void A_block_falls_into_holes_it_covers_completely_and_fills_them() - { - const string Before = "1ao.."; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(".1...", Ascii.Draw(board, Before)); - Assert.Equal(StepResult.Walked, board.Step(Direction.PlusX)); - Assert.Equal(StepResult.Walked, board.Step(Direction.PlusX)); - } - - [Fact] - public void A_fall_fills_only_the_covered_holes() - { - const string Before = """ - 1ao - .ao - ..o - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(""" - .1. - ... - ..o - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void A_block_partly_over_holes_stays_up_and_its_holes_stay_open() - { - const string Before = """ - 1a.. - .ao. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(""" - .1a. - ..a. - """, Ascii.Draw(board, Before)); - Assert.Equal(Cell.HoleFlag, board[2, 1] & Cell.HoleFlag); - } - - [Fact] - public void A_block_cell_resting_on_a_hole_cannot_be_pushed_but_its_other_cells_can() - { - const string Before = """ - 1a.. - .ao. - """; - var board = Ascii.Board(Before); - board.Step(Direction.PlusX); - board.Step(Direction.PlusY); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusX)); - - board.Step(Direction.MinusY); - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusX)); - Assert.Equal(""" - ..1a - ..oa - """, Ascii.Draw(board, Before)); - } -} diff --git a/tests/PuzzleBoy.Tests/PuzzleBoy.Tests.csproj b/tests/PuzzleBoy.Tests/PuzzleBoy.Tests.csproj index a4f3fc5..5ad00ec 100644 --- a/tests/PuzzleBoy.Tests/PuzzleBoy.Tests.csproj +++ b/tests/PuzzleBoy.Tests/PuzzleBoy.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/tests/PuzzleBoy.Tests/StageTests.cs b/tests/PuzzleBoy.Tests/StageTests.cs deleted file mode 100644 index 8788c19..0000000 --- a/tests/PuzzleBoy.Tests/StageTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Buffers.Binary; -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class StageTests -{ - private const ushort Wall = 0x2000; - private const ushort Block = 0x600A; - - [Fact] - public void Parse_reads_characters_goal_and_cells() - { - var stage = Stage.Parse(StageFile(enabled: [0], starts: [(17, 2)], goal: (2, 2), cells: c => c[(5 * Stage.Width) + 3] = Block)); - - Assert.Equal([(0, 17, 2)], stage.Characters); - Assert.Equal((2, 2), stage.Goal); - Assert.Equal(Block, stage[3, 5]); - Assert.Equal(Wall, stage[0, 0]); - } - - [Fact] - public void Parse_picks_characters_by_enable_byte_and_keeps_their_slot() - { - var stage = Stage.Parse(StageFile(enabled: [2, 3], starts: [(6, 8), (6, 7)], goal: (7, 13))); - - Assert.Equal([(2, 6, 8), (3, 6, 7)], stage.Characters); - } - - [Fact] - public void Parse_undoes_crlf_damage() - { - // 10 is 0A, the byte a CRLF conversion rewrites: in a start position, the goal and a block group. - var file = StageFile(enabled: [0], starts: [(10, 3)], goal: (4, 10), cells: c => c[(3 * Stage.Width) + 5] = Block); - var damaged = file.SelectMany(b => b == 0x0A ? new byte[] { 0x0D, 0x0A } : [b]).ToArray(); - Assert.True(damaged.Length > Stage.FileSize); - - var stage = Stage.Parse(damaged); - - Assert.Equal([(0, 10, 3)], stage.Characters); - Assert.Equal((4, 10), stage.Goal); - Assert.Equal(Block, stage[5, 3]); - } - - [Fact] - public void Parse_rejects_a_file_of_the_wrong_size() => - Assert.Throws(() => Stage.Parse(new byte[Stage.FileSize - 1])); - - [Fact] - public void Parse_rejects_a_count_that_disagrees_with_the_enable_bytes() - { - var file = StageFile(enabled: [0, 1], starts: [(1, 1), (2, 2)], goal: (3, 3)); - BinaryPrimitives.WriteInt32LittleEndian(file, 1); - - Assert.Throws(() => Stage.Parse(file)); - } - - [Fact] - public void Parse_rejects_a_position_outside_the_grid() => - Assert.Throws(() => Stage.Parse(StageFile(enabled: [0], starts: [(20, 1)], goal: (3, 3)))); - - /// A handmade stage file: walls everywhere, one start position per enabled slot. - private static byte[] StageFile(int[] enabled, (short X, short Y)[] starts, (short X, short Y) goal, Action? cells = null) - { - var grid = Enumerable.Repeat(Wall, Stage.Width * Stage.Height).ToArray(); - cells?.Invoke(grid); - - var file = new byte[Stage.FileSize]; - BinaryPrimitives.WriteInt32LittleEndian(file, enabled.Length); - for (var i = 0; i < enabled.Length; i++) - { - file[4 + enabled[i]] = 1; - BinaryPrimitives.WriteInt16LittleEndian(file.AsSpan(8 + (enabled[i] * 4)), starts[i].X); - BinaryPrimitives.WriteInt16LittleEndian(file.AsSpan(10 + (enabled[i] * 4)), starts[i].Y); - } - - BinaryPrimitives.WriteInt16LittleEndian(file.AsSpan(24), goal.X); - BinaryPrimitives.WriteInt16LittleEndian(file.AsSpan(26), goal.Y); - for (var i = 0; i < grid.Length; i++) - { - BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(28 + (i * 2)), grid[i]); - } - - return file; - } -} diff --git a/tests/PuzzleBoy.Tests/SwitchTests.cs b/tests/PuzzleBoy.Tests/SwitchTests.cs deleted file mode 100644 index d37b39d..0000000 --- a/tests/PuzzleBoy.Tests/SwitchTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class SwitchTests -{ - [Fact] - public void Switching_hands_control_to_the_next_character_and_wraps_around() - { - var board = Ascii.Board("1.2.3"); - - Assert.True(board.Switch()); - Assert.Equal(1, board.Active.Slot); - Assert.True(board.Switch()); - Assert.Equal(2, board.Active.Slot); - Assert.True(board.Switch()); - Assert.Equal(0, board.Active.Slot); - } - - [Fact] - public void Switching_skips_characters_that_already_reached_the_goal() - { - var board = Ascii.Board("1G2.3"); - - board.Step(Direction.PlusX); - Assert.Equal(1, board.Active.Slot); - - board.Switch(); - Assert.Equal(2, board.Active.Slot); - board.Switch(); - Assert.Equal(1, board.Active.Slot); - } - - [Fact] - public void A_lone_character_or_a_cleared_board_refuses_to_switch() - { - var alone = Ascii.Board("1G2"); - alone.Step(Direction.PlusX); - - Assert.False(alone.Switch()); - Assert.Equal(1, alone.Active.Slot); - - alone.Step(Direction.MinusX); - Assert.True(alone.IsCleared); - Assert.False(alone.Switch()); - } - - [Fact] - public void A_switch_is_not_recorded_and_undo_returns_control_to_the_pusher() - { - const string Before = "1a.2"; - var board = Ascii.Board(Before); - - Assert.True(board.Switch()); - Assert.False(board.CanUndo); - Assert.Null(board.LastMove); - - board.Switch(); - board.Step(Direction.PlusX); - board.Switch(); - Assert.Equal(1, board.Active.Slot); - - board.Undo(); - Assert.Equal(0, board.Active.Slot); - Assert.Equal(Before, Ascii.Draw(board, Before)); - } - - [Fact] - public void A_switch_lets_the_waiting_character_make_the_push_only_it_can_reach() - { - // The unused multi-character stages need this: only 2's cell pushes the block into the hole, and 1 can't - // get there while 2 waits in it. - const string Before = """ - #2## - 1a.. - #o## - #G## - """; - var board = Ascii.Board(Before); - - board.Switch(); - Assert.Equal(StepResult.Pushed, board.Step(Direction.PlusY)); - board.Step(Direction.PlusY); - board.Step(Direction.PlusY); - Assert.Equal(0, board.Active.Slot); - - board.Step(Direction.PlusX); - board.Step(Direction.PlusY); - board.Step(Direction.PlusY); - Assert.True(board.IsCleared); - } -} diff --git a/tests/PuzzleBoy.Tests/SwivelTests.cs b/tests/PuzzleBoy.Tests/SwivelTests.cs deleted file mode 100644 index 395c671..0000000 --- a/tests/PuzzleBoy.Tests/SwivelTests.cs +++ /dev/null @@ -1,277 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class SwivelTests -{ - [Fact] - public void Pushing_an_arm_turns_the_swivel_a_quarter_turn_along_the_push() - { - const string Before = """ - ..... - .1... - .RC.. - ..... - ..... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusY)); - Assert.Equal(""" - ..... - ..... - .1C.. - ..R.. - ..... - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void Pushing_an_arm_from_the_other_side_turns_the_other_way() - { - const string Before = """ - ..... - ...1. - ..CR. - ..... - ..... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusY)); - Assert.Equal(""" - ..... - ..... - ..C1. - ..R.. - ..... - """, Ascii.Draw(board, Before)); - } - - [Theory] - [InlineData(0, -1, Direction.PlusX)] - [InlineData(0, -1, Direction.MinusX)] - [InlineData(1, 0, Direction.PlusY)] - [InlineData(1, 0, Direction.MinusY)] - [InlineData(0, 1, Direction.PlusX)] - [InlineData(0, 1, Direction.MinusX)] - [InlineData(-1, 0, Direction.PlusY)] - [InlineData(-1, 0, Direction.MinusY)] - public void The_pushed_arm_always_ends_on_the_side_the_push_points_to(int armX, int armY, Direction push) - { - var (dx, dy) = Offset(push); - var (cx, cy) = (4, 4); - var drawing = Room(9, 9, (cx, cy, 'C'), (cx + armX, cy + armY, 'R'), (cx + armX - dx, cy + armY - dy, '1')); - var board = Ascii.Board(drawing); - - Assert.Equal(StepResult.Turned, board.Step(push)); - Assert.Equal(0xA020, board[cx + dx, cy + dy]); - Assert.Equal((cx + armX, cy + armY), (board.Active.X, board.Active.Y)); - } - - [Fact] - public void Pushing_an_arm_toward_the_center_is_refused() - { - const string Before = """ - ..1.. - ..R.. - ..C.. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusY)); - Assert.Equal(Before, Ascii.Draw(board, Before)); - } - - [Theory] - [InlineData(""" - ..... - .1... - .RC.. - .#... - """)] - [InlineData(""" - ..... - .1... - .RC.. - ..#.. - """)] - [InlineData(""" - ..... - .1... - .RC.. - ..Ra. - """)] - public void A_wall_or_another_group_on_the_swept_or_landing_cells_refuses_the_turn(string before) - { - // Cases: a wall on the pushed arm's swept cell, a wall where it lands, and a block on the second arm's swept cell. - var board = Ascii.Board(before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusY)); - Assert.Equal(before, Ascii.Draw(board, before)); - } - - [Fact] - public void An_arm_landing_on_the_swivels_own_cell_is_allowed() - { - const string Before = """ - ..... - .1... - .RC.. - ..R.. - ..... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusY)); - Assert.Equal(""" - ..... - ..... - .1CR. - ..R.. - ..... - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void Another_swivels_arm_on_a_swept_cell_refuses_the_turn() - { - const string Before = """ - ..... - .1... - .RC.. - ..RS. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusY)); - } - - [Fact] - public void Holes_never_block_a_turn_and_an_arm_can_rest_on_a_hole_without_filling_it() - { - const string Before = """ - ..... - .1... - .RC.. - .oo.. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusY)); - Assert.Equal(""" - ..... - ..... - .1C.. - .oH.. - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void An_arm_resting_on_a_hole_cannot_be_pushed_directly() - { - // Stage 36, the sixth stage the original plays, has an arm like this. - const string Before = """ - ..... - ..R.. - ..C.. - .1H.. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusX)); - } - - [Fact] - public void Turning_an_arm_off_a_hole_leaves_the_hole_open() - { - const string Before = """ - ..... - .1R.. - ..C.. - ..H.. - ..... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusX)); - Assert.Equal(""" - ..... - ..1.. - .RCR. - ..o.. - ..... - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void The_character_moves_two_cells_when_the_swivel_is_beside_it() - { - const string Before = """ - ..... - .1R.. - .RC.. - ..... - ..... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusY)); - Assert.Equal(""" - ..... - ..... - .RC.. - .1R.. - ..... - """, Ascii.Draw(board, Before)); - } - - [Fact] - public void A_hole_where_a_two_cell_step_ends_refuses_the_turn() - { - const string Before = """ - ..... - .1R.. - .RC.. - .o... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusY)); - } - - [Fact] - public void An_arm_can_sweep_over_and_stop_on_the_goal() - { - const string Before = """ - ..... - .1... - .RC.. - ..G.. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Turned, board.Step(Direction.PlusY)); - Assert.Equal(0xA020, board[2, 3]); - Assert.False(board.IsCleared); - } - - private static (int X, int Y) Offset(Direction direction) => direction switch - { - Direction.PlusY => (0, 1), - Direction.PlusX => (1, 0), - Direction.MinusY => (0, -1), - _ => (-1, 0), - }; - - private static string Room(int width, int height, params (int X, int Y, char C)[] things) - { - var rows = Enumerable.Range(0, height).Select(_ => Enumerable.Repeat('.', width).ToArray()).ToArray(); - foreach (var (x, y, c) in things) - { - rows[y][x] = c; - } - - return string.Join('\n', rows.Select(r => new string(r))); - } -} diff --git a/tests/PuzzleBoy.Tests/UndoTests.cs b/tests/PuzzleBoy.Tests/UndoTests.cs deleted file mode 100644 index c9b3157..0000000 --- a/tests/PuzzleBoy.Tests/UndoTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class UndoTests -{ - // A one-arm swivel the character can keep turning: each lap is four turns, each followed by a walk. - private const string Turnstile = """ - ..... - .1... - .RC.. - ..... - ..... - """; - - private static readonly Direction[] Lap = - [ - Direction.PlusY, Direction.PlusY, - Direction.PlusX, Direction.PlusX, - Direction.MinusY, Direction.MinusY, - Direction.MinusX, Direction.MinusX, - ]; - - [Fact] - public void Walking_records_nothing_to_undo() - { - const string Before = "1.."; - var board = Ascii.Board(Before); - board.Step(Direction.PlusX); - - Assert.False(board.CanUndo); - Assert.False(board.Undo()); - Assert.Equal(".1.", Ascii.Draw(board, Before)); - } - - [Fact] - public void Undo_returns_to_just_before_the_last_push_and_drops_the_walking_since() - { - const string Before = """ - .... - 1a.. - .... - """; - var board = Ascii.Board(Before); - board.Step(Direction.PlusX); - board.Step(Direction.MinusY); - board.Step(Direction.PlusX); - - Assert.True(board.Undo()); - Assert.Equal(Before, Ascii.Draw(board, Before)); - Assert.False(board.CanUndo); - } - - [Fact] - public void A_push_and_its_fall_are_one_undo_and_the_hole_reopens() - { - const string Before = "1ao."; - var board = Ascii.Board(Before); - board.Step(Direction.PlusX); - - Assert.True(board.Undo()); - Assert.Equal(Before, Ascii.Draw(board, Before)); - Assert.False(board.CanUndo); - } - - [Fact] - public void Undo_restores_a_swivel_turn() - { - var board = Ascii.Board(Turnstile); - board.Step(Direction.PlusY); - - Assert.True(board.Undo()); - Assert.Equal(Turnstile, Ascii.Draw(board, Turnstile)); - } - - [Fact] - public void Thirty_actions_can_all_be_undone() - { - var board = Ascii.Board(Turnstile); - Play(board, turns: 30); - - Assert.Equal(30, UndoAll(board)); - Assert.Equal(Turnstile, Ascii.Draw(board, Turnstile)); - } - - [Fact] - public void The_thirty_first_action_drops_the_oldest_undo() - { - var board = Ascii.Board(Turnstile); - Play(board, turns: 31); - - Assert.Equal(30, UndoAll(board)); - // The oldest kept undo is the one taken just before the second turn: after the first turn and its walk. - Assert.Equal(""" - ..... - ..... - ..C.. - .1R.. - ..... - """, Ascii.Draw(board, Turnstile)); - } - - [Fact] - public void A_new_action_after_an_undo_replaces_what_was_undone() - { - var board = Ascii.Board(Turnstile); - Play(board, turns: 2); - board.Undo(); - board.Step(Direction.PlusX); - - Assert.Equal(2, UndoAll(board)); - Assert.Equal(Turnstile, Ascii.Draw(board, Turnstile)); - } - - [Fact] - public void Undo_brings_back_a_character_that_reached_the_goal_and_gives_it_control() - { - const string Before = """ - G... - 1a.. - .2.. - """; - var board = Ascii.Board(Before); - board.Step(Direction.PlusX); - board.Step(Direction.MinusX); - board.Step(Direction.MinusY); - Assert.Equal(1, board.Active.Slot); - - Assert.True(board.Undo()); - Assert.Equal(0, board.Active.Slot); - Assert.Equal(Before, Ascii.Draw(board, Before)); - } - - [Fact] - public void Clearing_the_stage_wipes_the_undo_history() - { - const string Before = "G1a."; - var board = Ascii.Board(Before); - board.Step(Direction.PlusX); - board.Step(Direction.MinusX); - board.Step(Direction.MinusX); - - Assert.True(board.IsCleared); - Assert.False(board.CanUndo); - } - - [Fact] - public void Retry_restores_the_stage_and_wipes_the_undo_history() - { - var board = Ascii.Board(Turnstile); - Play(board, turns: 3); - - board.Retry(); - - Assert.Equal(Turnstile, Ascii.Draw(board, Turnstile)); - Assert.False(board.CanUndo); - } - - private static void Play(Board board, int turns) - { - for (var i = 0; i < turns; i++) - { - Assert.Equal(StepResult.Turned, board.Step(Lap[i * 2 % Lap.Length])); - Assert.Equal(StepResult.Walked, board.Step(Lap[(i * 2 % Lap.Length) + 1])); - } - } - - private static int UndoAll(Board board) - { - var count = 0; - while (board.Undo()) - { - count++; - } - - return count; - } -} diff --git a/tests/PuzzleBoy.Tests/WalkingTests.cs b/tests/PuzzleBoy.Tests/WalkingTests.cs deleted file mode 100644 index e745a48..0000000 --- a/tests/PuzzleBoy.Tests/WalkingTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -public sealed class WalkingTests -{ - [Fact] - public void A_step_onto_floor_moves_one_cell() - { - const string Before = """ - .... - .1.. - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Walked, board.Step(Direction.PlusX)); - Assert.Equal(""" - .... - ..1. - """, Ascii.Draw(board, Before)); - } - - [Theory] - [InlineData(Direction.PlusX)] - [InlineData(Direction.PlusY)] - [InlineData(Direction.MinusY)] - [InlineData(Direction.MinusX)] - public void Walls_holes_swivel_centers_and_the_grid_edge_refuse_a_step(Direction direction) - { - // Right is a wall, down a hole, up a swivel center, and left is off the grid. - const string Before = """ - C... - 1#.. - o... - """; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(direction)); - Assert.Equal(Before, Ascii.Draw(board, Before)); - } - - [Fact] - public void Another_character_refuses_a_step() - { - const string Before = "12."; - var board = Ascii.Board(Before); - - Assert.Equal(StepResult.Refused, board.Step(Direction.PlusX)); - Assert.Equal(Before, Ascii.Draw(board, Before)); - } -} From 9768050b832adee561388193c628df94de1cdcef Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:24:45 -0500 Subject: [PATCH 04/14] fix(preservation): restore original twenty-stage campaign The game loads exactly the original's 20 stage files again. The C switch key, the PageUp/PageDown stage skip and the switch's controls bar are gone; Hud.cs is back to v0.1.0. Everything else plays as before, and after stage 20 the board stays cleared, as in v0.1.0. Pressed now only reads keys from a table, so tests can prove there's no switch or skip key. A metadata scan proves the game never references Board.Switch, with Board.Step as its positive control. The bonus stages and the switch move to Community Edition. --- src/PuzzleBoy/GameAssets.cs | 11 +-- src/PuzzleBoy/Hud.cs | 10 +-- src/PuzzleBoy/PuzzleBoyGame.cs | 28 +++---- tests/PuzzleBoy.Tests/ControlsTests.cs | 17 +++++ tests/PuzzleBoy.Tests/PlayOrderTests.cs | 12 --- .../PuzzleBoy.Tests/PreservationScopeTests.cs | 76 +++++++++++++++++++ 6 files changed, 110 insertions(+), 44 deletions(-) delete mode 100644 tests/PuzzleBoy.Tests/PlayOrderTests.cs create mode 100644 tests/PuzzleBoy.Tests/PreservationScopeTests.cs diff --git a/src/PuzzleBoy/GameAssets.cs b/src/PuzzleBoy/GameAssets.cs index 95562e1..38eb224 100644 --- a/src/PuzzleBoy/GameAssets.cs +++ b/src/PuzzleBoy/GameAssets.cs @@ -14,14 +14,10 @@ internal sealed record GameAssets( IReadOnlyList<(int File, Stage Stage)> Stages, TextureData Atlas, TextureData Background, IReadOnlyDictionary Sounds, CharacterData Character, UiSheets Ui, TextData Text) { - /// The M4 bonus files played after stage 20; removed in the next commit, which restores the original 20. - internal static IReadOnlyList Bonus { get; } = [1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44]; - /// Reads every asset, opening each bundle once; common is large and slow to unpack. public static GameAssets Read(GameInstall install) { - var text = install.ReadTextAssets("pazzleboy"); - var stages = OriginalStageOrder.Files.Concat(Bonus).Select(file => (file, Stage.Parse(text[OriginalStageOrder.AssetName(file)]))).ToList(); + var stages = ReadStages(install.ReadTextAssets("pazzleboy")); var common = install.ReadTextures("common", "BG_pzl", "tone2", "pzlUI01", "pzlUI_font01_ja", "talkUI01"); var english = install.ReadTextures("ui_tex_en", "pzlUI_font01_en", "buttonUI01_en_common"); return new GameAssets( @@ -34,4 +30,9 @@ public static GameAssets Read(GameInstall install) install.ReadTexture("ui_tex_keyboard_en", "buttonUI01_en_keyboard"), common["talkUI01"], english["buttonUI01_en_common"]), install.ReadText()); } + + /// The original's 20 stages in its order, from the pazzleboy bundle's TextAssets by name. + /// A stage file is missing from . + internal static List<(int File, Stage Stage)> ReadStages(IReadOnlyDictionary text) => + [.. OriginalStageOrder.Files.Select(file => (file, Stage.Parse(text[OriginalStageOrder.AssetName(file)])))]; } diff --git a/src/PuzzleBoy/Hud.cs b/src/PuzzleBoy/Hud.cs index 673da33..bd5780b 100644 --- a/src/PuzzleBoy/Hud.cs +++ b/src/PuzzleBoy/Hud.cs @@ -31,13 +31,10 @@ internal enum BarSet /// PZL04, while help is open: Cancel. Help, - - /// PZL01 with the port's switch after Move, while more than one character is on the board. - Switch, } /// What the HUD shows this tick. -/// The stage number, from 1; bonus stages follow stage 20. +/// The stage number, 1 to 20. /// True while COMPLETE! shows. /// True while UNDO shows. /// The scroll arrows to show, one bit per screen direction: 1 up, 2 right, 4 down, 8 left. @@ -74,7 +71,6 @@ private static readonly (Vector2 Centre, Rectangle Sprite, Vector2 Inward)[] Scr // Key icons on the keyboard sheet (48x48). The original's PC keys: WASD move, Q/E turn, F menu, Tab scroll, V angle. private static readonly Rectangle KeyW = new(1612, 60, 48, 48), KeyA = new(28, 60, 48, 48), KeyS = new(1324, 60, 48, 48), KeyD = new(244, 60, 48, 48); private static readonly Rectangle KeyQ = new(1180, 60, 48, 48), KeyE = new(316, 60, 48, 48), KeyF = new(388, 60, 48, 48), KeyV = new(1540, 60, 48, 48); - private static readonly Rectangle KeyC = new(172, 60, 48, 48); private static readonly Rectangle KeyTab = new(460, 108, 48, 48), KeySpace = new(532, 108, 48, 48), KeyBackspace = new(316, 108, 48, 48); // In the menu and help, Space (or Enter) confirms and Backspace cancels; the original's keys for these are unknown. @@ -104,9 +100,6 @@ private static readonly (string Id, string Material, bool Centre, float X, float private static readonly (Rectangle[] Keys, string Label)[] PlaySets = [([KeyW, KeyA, KeyS, KeyD], "GUIDE_L0012"), ([KeyQ, KeyE], "GUIDE_L0021"), ([KeyF], "GUIDE_L0028"), ([KeyTab], "GUIDE_L0029"), ([KeyV], "GUIDE_L0032")]; - // The switch is the port's (ROADMAP.md, M4), but the game's guide text has its label: "Switch Character". - private static readonly (Rectangle[] Keys, string Label)[] SwitchSets = [PlaySets[0], ([KeyC], "GUIDE_L0004"), .. PlaySets[1..]]; - private static readonly (Rectangle[] Keys, string Label)[] ScrollSets = [([KeyW, KeyA, KeyS, KeyD], "GUIDE_L0016"), ([KeyQ, KeyE], "GUIDE_L0021"), ([KeyTab], "GUIDE_L0029"), ([KeyV], "GUIDE_L0032")]; @@ -282,7 +275,6 @@ public void Draw(HudState state) var sets = state.Bar switch { BarSet.Play => PlaySets, - BarSet.Switch => SwitchSets, BarSet.Scroll => ScrollSets, BarSet.Menu => MenuSets, BarSet.Help => HelpSets, diff --git a/src/PuzzleBoy/PuzzleBoyGame.cs b/src/PuzzleBoy/PuzzleBoyGame.cs index 7dcde45..648faf2 100644 --- a/src/PuzzleBoy/PuzzleBoyGame.cs +++ b/src/PuzzleBoy/PuzzleBoyGame.cs @@ -8,8 +8,7 @@ namespace PuzzleBoy; /// /// The game window: plays the original's stages in order with its tick rate, move timing, follow camera and board look /// (ROADMAP.md, "M3 findings"). Arrow keys or WASD move, Q and E turn the view (L1/R1), V switches to overhead (○), -/// Tab scrolls (△), C switches characters, F opens the menu (□), Enter or Space confirms, Backspace cancels, -/// PageUp/PageDown skip stages, Escape quits. +/// Tab scrolls (△), F opens the menu (□), Enter or Space confirms, Backspace cancels, Escape quits. /// public sealed class PuzzleBoyGame : Game { @@ -34,6 +33,10 @@ internal static readonly (Keys Key, int Screen)[] Moves = (Keys.Right, 1), (Keys.D, 1), ]; + // Every key the game reads as a press; Pressed refuses any other, so a test can see the whole set. The port's + // switch (C) and stage skipping (PageUp/PageDown) belong to Community Edition, not to this port. + internal static readonly Keys[] Presses = [Keys.Tab, Keys.V, Keys.Q, Keys.E, Keys.F, Keys.Enter, Keys.Space, Keys.Back]; + private readonly GameAssets _assets; private readonly ViewCamera _camera = new(); private BoardView _view = null!; @@ -166,12 +169,6 @@ protected override void Update(GameTime gameTime) Play(keys); } - // PageUp and PageDown skip stages, so the bonus stages don't need 20 stages played first. - if (Pressed(keys, Keys.PageDown) || Pressed(keys, Keys.PageUp)) - { - Load((_current + (Pressed(keys, Keys.PageDown) ? 1 : _assets.Stages.Count - 1)) % _assets.Stages.Count); - } - UpdateTitle(); _previousKeys = keys; base.Update(gameTime); @@ -189,7 +186,7 @@ protected override void Draw(GameTime gameTime) // COMPLETE! shows from the clear's first tick, the one after the last move ends, for 30 ticks. var clear = _board.IsCleared && _clearedFor is >= 4 and < 34; // The controls bar hides while the Manikin Kid talks and shows the menu's or help's keys while they're up. - var bar = _dialogue is not null ? BarSet.None : _helpOpen ? BarSet.Help : _menuOpen ? BarSet.Menu : _scroll is not null ? BarSet.Scroll : _board.Characters.Count(c => c.Visible) > 1 ? BarSet.Switch : BarSet.Play; + var bar = _dialogue is not null ? BarSet.None : _helpOpen ? BarSet.Help : _menuOpen ? BarSet.Menu : _scroll is null ? BarSet.Play : BarSet.Scroll; _hud.Draw(new HudState(_current + 1, clear, _undoTicks > 0, ScrollArrows(), _scrollTicks, bar, _barTicks)); if (_menuOpen) { @@ -230,7 +227,7 @@ private void Load(int index) _lock = StartLock; } - /// One tick of input, read in the original's order: △, ○, the directions, then L1/R1; the port's switch and □ last. + /// One tick of input, read in the original's order: △, ○, the directions, then L1/R1. private void Play(KeyboardState keys) { // shortcut: scroll mode draws no direction arrows yet; they're pzlUI sprites and come with the HUD. @@ -282,13 +279,6 @@ private void Play(KeyboardState keys) _sounds.Play(8); } - // The port's switch, which the original lacks (ROADMAP.md, M4). Its sound is the view change's, since the camera - // moves to the new character. - if (Pressed(keys, Keys.C) && _animation is null && _scroll is null && _board.Switch()) - { - _sounds.Play(8); - } - // □ opens the menu only in play: not in scroll mode, and not once a move or a turn has started this tick. if (Pressed(keys, Keys.F) && _animation is null && !_camera.IsTurning && _scroll is null) { @@ -442,7 +432,9 @@ private List PosedModels() return posed; } - private bool Pressed(KeyboardState keys, Keys key) => keys.IsKeyDown(key) && _previousKeys.IsKeyUp(key); + private bool Pressed(KeyboardState keys, Keys key) => Array.IndexOf(Presses, key) >= 0 + ? keys.IsKeyDown(key) && _previousKeys.IsKeyUp(key) + : throw new ArgumentOutOfRangeException(nameof(key), key, "Not one of the keys in Presses."); private void UpdateTitle() { diff --git a/tests/PuzzleBoy.Tests/ControlsTests.cs b/tests/PuzzleBoy.Tests/ControlsTests.cs index 0f3cb90..a8259da 100644 --- a/tests/PuzzleBoy.Tests/ControlsTests.cs +++ b/tests/PuzzleBoy.Tests/ControlsTests.cs @@ -44,5 +44,22 @@ public void Directions_are_read_up_down_left_right_like_the_original() Assert.Equal([0, 0, 2, 2, 3, 3, 1, 1], PuzzleBoyGame.Moves.Select(m => m.Screen)); } + [Fact] + public void The_game_reads_only_the_original_keys_and_the_menu_keys_as_presses() + { + Assert.Equal([Keys.Tab, Keys.V, Keys.Q, Keys.E, Keys.F, Keys.Enter, Keys.Space, Keys.Back], PuzzleBoyGame.Presses); + } + + // The switch and stage skipping are Community Edition's; the original has neither. + [Theory] + [InlineData(Keys.C)] + [InlineData(Keys.PageUp)] + [InlineData(Keys.PageDown)] + public void The_preservation_game_has_no_switch_or_stage_skip_key(Keys key) + { + Assert.DoesNotContain(key, PuzzleBoyGame.Presses); + Assert.DoesNotContain(key, PuzzleBoyGame.Moves.Select(m => m.Key)); + } + private static int ScreenOf(Keys key) => Assert.Single(PuzzleBoyGame.Moves, m => m.Key == key).Screen; } diff --git a/tests/PuzzleBoy.Tests/PlayOrderTests.cs b/tests/PuzzleBoy.Tests/PlayOrderTests.cs deleted file mode 100644 index d38d129..0000000 --- a/tests/PuzzleBoy.Tests/PlayOrderTests.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PuzzleBoy.Tests; - -public sealed class PlayOrderTests -{ - [Fact] - public void Bonus_stages_are_unused_files_each_played_once() - { - Assert.Empty(GameAssets.Bonus.Intersect(OriginalStageOrder.Files)); - Assert.Equal(GameAssets.Bonus.Count, GameAssets.Bonus.Distinct().Count()); - Assert.All(GameAssets.Bonus, file => Assert.InRange(file, 0, 49)); - } -} diff --git a/tests/PuzzleBoy.Tests/PreservationScopeTests.cs b/tests/PuzzleBoy.Tests/PreservationScopeTests.cs new file mode 100644 index 0000000..7fa3ec1 --- /dev/null +++ b/tests/PuzzleBoy.Tests/PreservationScopeTests.cs @@ -0,0 +1,76 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using PuzzleBoy.Rules; + +namespace PuzzleBoy.Tests; + +// This port plays the original's 20 stages and nothing else. The bonus stages and the switch moved to Community Edition. +public sealed class PreservationScopeTests +{ + // The 23 unused files M4 played after stage 20 (ROADMAP.md, M4). + private static readonly int[] BonusFiles = [1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44]; + + [Fact] + public void The_original_order_is_exactly_the_twenty_documented_files() + { + Assert.Equal([2, 4, 32, 8, 38, 36, 37, 48, 42, 49, 40, 20, 22, 13, 21, 24, 14, 10, 25, 26], OriginalStageOrder.Files); + } + + [Fact] + public void The_original_order_plays_each_file_once() + { + Assert.Equal(OriginalStageOrder.Files.Count, OriginalStageOrder.Files.Distinct().Count()); + } + + [Fact] + public void The_original_order_holds_no_bonus_file() + { + Assert.Empty(OriginalStageOrder.Files.Intersect(BonusFiles)); + } + + [Fact] + public void The_game_loads_only_the_original_stages_when_the_bundle_holds_all_fifty() + { + var file = Ascii.Stage("1.G").ToFile(); + var text = Enumerable.Range(0, 50).ToDictionary(OriginalStageOrder.AssetName, _ => file); + + Assert.Equal(OriginalStageOrder.Files, GameAssets.ReadStages(text).Select(stage => stage.File)); + } + + [Fact] + public void The_preservation_game_never_calls_Board_Switch() + { + Assert.DoesNotContain(nameof(Board.Switch), BoardMembersTheGameUses()); + } + + // Positive control: the scan does find a Board member the game really calls. + [Fact] + public void The_member_scan_finds_the_game_calling_Board_Step() + { + Assert.Contains(nameof(Board.Step), BoardMembersTheGameUses()); + } + + /// The names of the members the game's assembly references, read from its metadata. + private static List BoardMembersTheGameUses() + { + using var pe = new PEReader(File.OpenRead(typeof(PuzzleBoyGame).Assembly.Location)); + var metadata = pe.GetMetadataReader(); + var names = new List(); + foreach (var handle in metadata.MemberReferences) + { + var member = metadata.GetMemberReference(handle); + if (member.Parent.Kind != HandleKind.TypeReference) + { + continue; + } + + var type = metadata.GetTypeReference((TypeReferenceHandle)member.Parent); + if (metadata.GetString(type.Namespace) == typeof(Board).Namespace && metadata.GetString(type.Name) == nameof(Board)) + { + names.Add(metadata.GetString(member.Name)); + } + } + + return names; + } +} From 1e8062d8c5a7b361303b4dc580f5d51e962ba6a4 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:28:11 -0500 Subject: [PATCH 05/14] docs: distinguish preservation port from community edition The README now leads with what this repo is: a faithful preservation port that needs your own Nocturne data and plays the original 20 stages. It points to Community Edition for play without Nocturne. AGENTS.md and the docs cover the engine submodule, OriginalStageOrder and the keys without the switch or the skip. The bonus stages page stays as history. ROADMAP.md records the split's decisions and closes M5. --- AGENTS.md | 13 +++++++------ README.md | 11 +++++++---- ROADMAP.md | 27 +++++++++++++++++++-------- docs/README.md | 2 +- docs/architecture.md | 15 ++++++++------- docs/bonus-stages.md | 5 ++++- docs/journey.md | 4 ++-- docs/keeping-it-honest.md | 6 ++++-- docs/rules.md | 10 ++++++---- docs/timing-and-camera.md | 2 +- 10 files changed, 59 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79ab600..a7159de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -These are the working rules for agents in this repo. Puzzle Boy is a standalone, cross-platform rebuild of the Puzzle Boy minigame from *Shin Megami Tensei III Nocturne HD Remaster*, licensed Apache-2.0. +These are the working rules for agents in this repo. Puzzle Boy is a faithful, cross-platform preservation port of the Puzzle Boy minigame from *Shin Megami Tensei III Nocturne HD Remaster*, licensed Apache-2.0. ## Sources of truth @@ -13,6 +13,7 @@ These are the working rules for agents in this repo. Puzzle Boy is a standalone, - **No decompiled code in the source.** Turn what the decompiler shows into a test on a handmade stage, then write the code from the test. Don't paste or transliterate pseudocode. - **No circumvention.** `GameAssembly.dll` is protected by Denuvo Anti-Tamper. Analyze only its unprotected `.text` section, statically. Don't dump the running game's memory, de-obfuscate protected code or patch the game. If a function leads into protected code, stop and answer the question by playing the real game. - **No secrets or private paths** in committed files. +- **The original's scope only.** The game plays the original's 20 stages, in its order. Bonus stages, the switch key, stage skipping and anything else the original lacks belong to Puzzle Boy Community Edition. ## Reverse engineering @@ -24,18 +25,18 @@ These are the working rules for agents in this repo. Puzzle Boy is a standalone, ## Commands +- `git submodule update --init` fetches the rules engine into `external/puzzle-boy-engine`. Until the engine is on GitHub, point the submodule at a local clone first: `git config submodule.external/puzzle-boy-engine.url `, then `git -c protocol.file.allow=always submodule update --init`. - `dotnet build PuzzleBoy.slnx` builds everything. Warnings are errors. - `dotnet test --solution PuzzleBoy.slnx` runs the tests. They use handmade stages only and never need the game installed. - `dotnet format PuzzleBoy.slnx --verify-no-changes` checks the style CI will enforce. -- `dotnet run --project src/PuzzleBoy` starts the game, finding the install through Steam. `dotnet run --project src/PuzzleBoy -- ` points it at a copy elsewhere. Arrow keys or WASD move (hold to keep walking), Q and E turn the view (L1/R1), V switches to overhead (○), Tab scrolls (△), C switches characters on multi-character stages, F opens the menu with undo, restart, help and quit (□), Enter or Space confirms, Backspace cancels, PageUp/PageDown skip stages, Escape quits. The game runs at the original's 30 ticks a second. +- `dotnet run --project src/PuzzleBoy` starts the game, finding the install through Steam. `dotnet run --project src/PuzzleBoy -- ` points it at a copy elsewhere. Arrow keys or WASD move (hold to keep walking), Q and E turn the view (L1/R1), V switches to overhead (○), Tab scrolls (△), F opens the menu with undo, restart, help and quit (□), Enter or Space confirms, Backspace cancels, Escape quits. The game runs at the original's 30 ticks a second. - `scripts/check-no-game-data.sh` fails if tracked files hold Atlus data or decompiler output. `scripts/check-no-game-data.test.sh` is its positive control: it plants each kind in a scratch repo and expects a failure. When you add a new kind, add both a check and a planted case. ## Code layout -- `src/PuzzleBoy.Rules`: stage parsing (`Stage`, `Cell`), the rules (`Board`) and the original's play order (`PlayOrder`). No MonoGame and no file I/O, so the tests can drive it with plain bytes. -- Rules tests draw handmade stages as text with `tests/PuzzleBoy.Tests/Ascii.cs`; its summary has the legend. A rule change starts with a drawing of the case. -- `src/PuzzleBoy`: the MonoGame (DesktopGL) game. `GameInstall` finds the player's Steam install, or takes the game folder as the first argument, and reads bundles with AssetsTools.NET. It has no content pipeline; every asset is read at runtime. -- `tests/PuzzleBoy.Tests`: xunit.v3 on Microsoft Testing Platform, covering both projects. Test names read as sentences. +- `external/puzzle-boy-engine`: the shared rules engine, a pinned git submodule. `PuzzleBoy.Rules` has stage parsing (`Stage`, `Cell`) and the rules (`Board`), with no MonoGame and no file I/O. Don't edit it here: a rule change and its test go into the engine repo first, then this repo bumps the pin with `build: bump engine to `. +- `src/PuzzleBoy`: the MonoGame (DesktopGL) game. `OriginalStageOrder` lists the original's 20 stage files in order. `GameInstall` finds the player's Steam install, or takes the game folder as the first argument, and reads bundles with AssetsTools.NET. It has no content pipeline; every asset is read at runtime. +- `tests/PuzzleBoy.Tests`: xunit.v3 on Microsoft Testing Platform, covering the game. The rules tests live in the engine. Tests that need a board draw it as text with `Ascii.cs`; its summary has the legend. Test names read as sentences. ## Working style diff --git a/README.md b/README.md index 911ff69..c8dbc4e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -**The Puzzle Boy minigame from *Shin Megami Tensei III: Nocturne*, as a standalone game.** +**A faithful preservation port of the Puzzle Boy minigame from *Shin Megami Tensei III Nocturne HD Remaster*. It reads your own copy of the game and plays the original 20 stages.**

Jack-o'-Lantern turns the view, pushes blocks and clears stage 2 @@ -14,7 +14,9 @@ In Nocturne, Puzzle Boy sits deep inside a long RPG. You guide Jack-o'-Lantern through a maze by pushing blocks, turning swivel blocks and filling holes, while the Manikin Kid taunts you. It is a 3D take on Atlus's Game Boy puzzler of the same name, released in the West as Kwirk. -This project rebuilds the minigame so it runs on its own on Windows, macOS and Linux. It reads the stages, text and sounds from your own copy of *Shin Megami Tensei III Nocturne HD Remaster*. +This project rebuilds the minigame so it runs on its own on Windows, macOS and Linux, as a record of how the original plays. It reads the stages, text and sounds from your own copy of *Shin Megami Tensei III Nocturne HD Remaster*. + +> **Want to play without Nocturne?** [Puzzle Boy Community Edition](https://github.com/hazeliscoding/puzzle-boy-community) is a separate game on the same rules, with its own stages and art. It installs and plays on its own, with no copy of Nocturne. It isn't published yet. > **Status:** early development. The original's rules are decoded and implemented, and all 20 stages can be played from your install with the original's board look, camera, timing, sound, HUD, dialogue, menu and an animated Jack-o'-Lantern. Solutions recorded in the original clear every stage in the port. See [ROADMAP.md](ROADMAP.md). @@ -27,7 +29,7 @@ This repository contains no Atlus code or data: no stages, text, audio, textures 1. Download the archive for your system from [Releases](https://github.com/hazeliscoding/puzzle-boy/releases) and unpack it. 2. Run `PuzzleBoy` (`PuzzleBoy.exe` on Windows). It looks for the game beside itself, then in your Steam libraries. If no window opens, it didn't find the game: run it from a terminal with the folder that holds `smt3hd.exe`, for example `PuzzleBoy "D:\Games\smt3hd"`. -Arrow keys or WASD move, Q and E turn the view, V switches to overhead, Tab scrolls, C switches characters on bonus stages with several, F opens the menu (undo, restart, help, quit), Enter or Space confirms, Backspace cancels, PageUp and PageDown skip stages, and Escape quits. Bonus stages, the stage files the original ships but never plays, follow stage 20. +Arrow keys or WASD move, Q and E turn the view, V switches to overhead, Tab scrolls, F opens the menu (undo, restart, help, quit), Enter or Space confirms, Backspace cancels, and Escape quits. After stage 20 the board stays cleared. v0.2.0 also played 23 bonus stages after stage 20; they now belong to Community Edition. The builds aren't signed, so Windows SmartScreen and macOS Gatekeeper may warn before the first start. On macOS, `xattr -dr com.apple.quarantine` on the unpacked folder lets it run. Nocturne HD has no Mac version, so a Mac needs a copy of the game folder from a PC. Nobody has played the macOS builds yet, so reports are welcome. @@ -36,7 +38,8 @@ The builds aren't signed, so Windows SmartScreen and macOS Gatekeeper may warn b - The 50 stage files in the game's `pazzleboy` asset bundle are small binary grids. Their format is decoded and recorded in the roadmap. - The rules come from static analysis of the game's code with [REA](https://rea.tools) and Ghidra. The analysis reads only the game's unprotected code and does not bypass its anti-tamper protection. - Each rule becomes a test on a handmade stage, and the game is written from those tests, never from decompiler output. -- [The docs](docs/README.md) walk through all of it, a few minutes per topic: the journey, the architecture, the rules, the timing, reading the game's files, the rendering, the bonus stages and the checks. +- The rules live in [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), which Community Edition shares. This repo pins it as a git submodule, so clone with `--recurse-submodules`. The engine isn't published yet either. +- [The docs](docs/README.md) walk through all of it, a few minutes per topic: the journey, the architecture, the rules, the timing, reading the game's files, the rendering, the bonus stages (now in Community Edition) and the checks. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 713c494..bf72de5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,6 +44,13 @@ Puzzle Boy is a standalone, cross-platform rebuild of the Puzzle Boy minigame fr - **Only stages with a known solution ship, easiest first.** Each bonus stage needs a solution that clears in the port. They're ordered by the pushes and turns in the solution found, so the tutorial-sized files (00, 01, 30, 31) come first. - **The bundles can sit beside the executable.** Without an argument, the game first looks for `smt3hd_Data` next to `PuzzleBoy`, then searches Steam. A player can copy the eight bundles it reads there and uninstall Nocturne. The release archives still ship no Atlus data. +## Decisions (2026.10.15) + +- **This port plays the original's 20 stages, and nothing else.** `OriginalStageOrder` replaces `PlayOrder.Files` in the game, and `GameAssets` loads only those files. The C switch, the switch's controls bar and PageUp/PageDown go. PageUp/PageDown predate M4 as a developer shortcut in v0.1.0, but the original has no stage skip, so it goes too. After stage 20 the board stays cleared, as in v0.1.0. +- **Every key read as a press is in one table.** `PuzzleBoyGame.Presses` lists them, and `Pressed` throws on any other key. A test pins the table, so a switch or skip key can't come back unseen. Held keys (the directions and Escape) are read directly, as before. +- **The game-data guard skips submodules.** `git ls-files` lists the engine submodule as a directory, which stopped the check. The engine repo runs the same check on its own files. The positive control now adds a submodule to every scratch repo. +- **CI needs the engine on GitHub.** `.gitmodules` names `https://github.com/hazeliscoding/puzzle-boy-engine.git`, which isn't published yet, so CI here can't check out until it is. Local clones override the URL in `.git/config` (AGENTS.md, Commands). + ## M0 findings (2026.10.14) Read from the decompiled game code through REA. Seven readers each covered one area, and an adversarial reviewer per area re-checked every claim against the pseudocode: 111 claims, 97 confirmed, 10 refuted or corrected, 4 left uncertain. Only the confirmed and corrected claims are recorded here. The IDs are short REA Evidence IDs; the pseudocode and the full results stay in `re/`. @@ -238,7 +245,7 @@ The stage files the original ships but never plays, after stage 20. See the deci **Done when** every bonus stage that ships has a solution that clears it in the port, and the switch's rules have tests. -## M5: Split into three repos (started 2026.10.14) +## M5: Split into three repos (done 2026.10.15) This repo goes back to being a faithful preservation port of the original 20 stages. The rules move to a shared engine, and everything the original never had moves to a standalone Community Edition that needs no Nocturne. @@ -247,26 +254,30 @@ This repo goes back to being a faithful preservation port of the original 20 sta - **Baseline** (observed, before any change): HEAD `410fc89`, tags `v0.1.0` and `v0.2.0`, local tag `pre-split-baseline` on `410fc89`. Build clean, 164 tests pass, format clean, and the no-game-data check and its positive control pass. `PlayOrder.Files` is `2, 4, 32, 8, 38, 36, 37, 48, 42, 49, 40, 20, 22, 13, 21, 24, 14, 10, 25, 26`; `PlayOrder.Bonus` is `1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44`. `re/` is local and untouched. - The split happens on the `split/three-repos` branch. Nothing is pushed and no GitHub repo is created until the owner says so. -- [ ] Extract the rules into `puzzle-boy-engine`, with their tests. -- [ ] Depend on the pinned engine and restore the original 20-stage campaign: no bonus stages, no switch key, no stage skipping. -- [ ] Update the docs to tell the preservation port apart from Community Edition. +- [x] Extract the rules into `puzzle-boy-engine`, with their tests. + - The engine holds `PuzzleBoy.Rules` and 86 of the 164 tests (the Cell, Goal, LastMove, Pushing, Stage, Switch, Swivel, Undo and Walking tests), unchanged but for their namespace. `PlayOrder` isn't in the engine. The engine adds a stage authoring API and has 202 tests. +- [x] Depend on the pinned engine and restore the original 20-stage campaign: no bonus stages, no switch key, no stage skipping. + - The submodule is pinned at engine `99eaf06`, and `src/PuzzleBoy.Rules` is gone. 78 tests stayed here after the move. The bonus-order test gave way to 10 new ones, for 87. + - Tests prove the order is exactly the 20 documented files, each once, with none of the 23 bonus files; that `GameAssets` loads only those 20 from a bundle holding all 50; that the game never references `Board.Switch` (a metadata scan, with `Board.Step` as the positive control); and that no switch or skip key is read. Adding a `Board.Switch` call or a PageDown press fails them. + - `Hud.cs` is back to v0.1.0, and `PuzzleBoyGame.cs` differs from v0.1.0 only by the key table and the removed skip. +- [x] Update the docs to tell the preservation port apart from Community Edition. + - README, AGENTS.md and the docs. `docs/bonus-stages.md` stays as history, with a note that bonus play moved to Community Edition. **Done when** this repo builds and tests against the pinned engine, plays exactly the 20 original stages, and passes every CI gate including the no-game-data check. +**Done:** against engine `99eaf06`, the build has no warnings, 87 tests pass, format is clean, and the no-game-data check and its positive control pass. A fresh local clone with the submodule URL overridden builds and passes too. Not yet run on GitHub, since the engine isn't published. Playing the 20 stages from the owner's install hasn't been repeated since the split. + ## Later -- A stage editor. The original ships a developer editor (`pbEdit`). -- Custom stage packs. - Discord Rich Presence: the stage you're on ("Stage 7 of 20") and time played, with a setting to turn it off. Needs a Discord application, whose name and image come from the brand (no Atlus art), and an MIT client library such as DiscordRichPresence. It does nothing when Discord isn't running. - An Android build. MonoGame targets Android, and the rules library carries over as is. A phone has no Steam install, so the player copies the few bundles Puzzle Boy reads from their PC install (about 85 MB, most of it `common`) and picks them in the app. Needs touch controls: swipe to move, buttons to turn the view. -- A fifth □ menu item, Bonus, that jumps to the bonus stages. Its label has no text in the game's tables. -- The seven unsettled stage files (16, 17, 18, 23, 27, 28 and 29), if a stronger search or a person finds a solution that clears the port. ## Not planned - Shipping or downloading any Atlus asset. - Circumventing Denuvo, patching the original game or modding it. - Nocturne's other minigames. +- Anything the original lacks: bonus stages, a switch key, stage skipping, a stage editor and custom stage packs. They belong to Community Edition (M5), along with the seven unsettled stage files. ## How we'll know it works diff --git a/docs/README.md b/docs/README.md index df7aa57..41892f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ | [Timing and camera](timing-and-camera.md) | Ticks, move lengths, input order and the camera | 4 min | | [Reading the game](reading-the-game.md) | Bundles, textures, sound, Jack's model and the text | 5 min | | [Rendering and UI](rendering-and-ui.md) | The board's look, Jack, the HUD and a font drawn on the CPU | 5 min | -| [Bonus stages](bonus-stages.md) | The 30 stages the original never plays, and the switch | 4 min | +| [Bonus stages](bonus-stages.md) | The 30 stages the original never plays, and the switch. History: they're Community Edition's now | 4 min | | [Keeping it honest](keeping-it-honest.md) | Tests, replays, footage checks, CI and the guardrails | 4 min | ## How the pages connect diff --git a/docs/architecture.md b/docs/architecture.md index 8cc730a..9edeffb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture > [!TIP] -> **TL;DR:** There are two projects. `PuzzleBoy.Rules` holds the game's logic, with no graphics and no file access, so tests drive it with plain bytes. `PuzzleBoy` is the MonoGame app: it reads everything from your install once at startup, then runs the original's 30-tick loop. +> **TL;DR:** There are two projects, plus the engine. `PuzzleBoy.Rules` holds the game's logic, with no graphics and no file access, so tests drive it with plain bytes. It comes from [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), a git submodule in `external/puzzle-boy-engine` that Community Edition shares. `PuzzleBoy` is the MonoGame app: it reads everything from your install once at startup, then runs the original's 30-tick loop. **Read time:** 4 minutes @@ -13,17 +13,17 @@ flowchart LR bundles[("Unity bundles
stages, text, sounds,
textures, Jack")] end subgraph app["PuzzleBoy (MonoGame)"] + order["OriginalStageOrder
which files, in what order"] --> ga gi["GameInstall
finds and reads the bundles"] --> ga["GameAssets
everything, decoded once"] ga --> loop["PuzzleBoyGame
the tick loop"] loop --> views["BoardView, CharacterView,
Hud, Sounds"] end - subgraph rules["PuzzleBoy.Rules"] + subgraph rules["PuzzleBoy.Rules, from the engine submodule"] stage["Stage
parses 828-byte files"] --> board["Board
steps, pushes, swivels, undo"] - order["PlayOrder
which files, in what order"] end bundles --> gi ga -- "stage bytes" --> stage - loop -- "Step, Switch, Undo" --> board + loop -- "Step, Undo, Retry" --> board ``` ## The trick that keeps it simple @@ -65,8 +65,8 @@ sequenceDiagram | Type | Job | |---|---| | `Stage` | Parses a stage file, repairing the six damaged ones | -| `Board` | The rules: steps, pushes, falls, swivels, the goal, the switch, undo and retry | -| `PlayOrder` | The original's 20 stages, then the bonus stages | +| `Board` | The rules: steps, pushes, falls, swivels, the goal, undo and retry. Its switch is Community Edition's; this port never calls it | +| `OriginalStageOrder` | The original's 20 stage files, in its order | | `GameInstall` | Finds the Steam install and reads bundles with AssetsTools.NET | | `GameAssets` | Reads and decodes every asset once at startup, in about 2 seconds | | `PuzzleBoyGame` | The tick loop: input, menus, dialogue, stage flow | @@ -88,5 +88,6 @@ sequenceDiagram ## Where to look - `src/PuzzleBoy/PuzzleBoyGame.cs`: start at `Update`, then `Play`. -- `src/PuzzleBoy.Rules/Board.cs`: start at `Step`. +- `external/puzzle-boy-engine/src/PuzzleBoy.Rules/Board.cs`: start at `Step`. - `tests/PuzzleBoy.Tests/Ascii.cs`: how tests draw stages as text. +- `tests/PuzzleBoy.Tests/PreservationScopeTests.cs`: the checks that keep this port to the original's 20 stages. diff --git a/docs/bonus-stages.md b/docs/bonus-stages.md index 451f4d4..92f20be 100644 --- a/docs/bonus-stages.md +++ b/docs/bonus-stages.md @@ -1,9 +1,12 @@ # Bonus stages +> [!NOTE] +> **History.** This page records M4, which shipped in v0.2.0. Since the split into three repos, this port plays only the original 20 stages, with no switch key and no stage skipping. Bonus play lives in [Puzzle Boy Community Edition](https://github.com/hazeliscoding/puzzle-boy-community), which imports these stages from the player's own install. `Board.Switch` stays in the shared engine. The paths below are as of v0.2.0. + > [!TIP] > **TL;DR:** The game ships 50 stage files and plays 20. In the port, 23 of the other 30 come after stage 20: every one with a solution that clears the port. Six of the seven stages with several Jack-o'-Lanterns can't be finished under the original's rules, because only the first character can move until it reaches the goal. The port adds a switch key, C, and with it four of them can be won. -**Read time:** 4 minutes · **Status:** M4, done · **Code:** `PlayOrder.Bonus`, `Board.Switch` +**Read time:** 4 minutes · **Status:** M4, done; moved to Community Edition in M5 · **Code:** `PlayOrder.Bonus`, `Board.Switch` ## The 50 files diff --git a/docs/journey.md b/docs/journey.md index 0a0f52c..27d2b01 100644 --- a/docs/journey.md +++ b/docs/journey.md @@ -54,7 +54,7 @@ timeline ### Some stages could never be finished -- Six of the seven stages with more than one Jack-o'-Lantern can't be won under the original's rules, because nothing lets you switch characters. The port adds a switch. See [Bonus stages](bonus-stages.md). +- Six of the seven stages with more than one Jack-o'-Lantern can't be won under the original's rules, because nothing lets you switch characters. M4 added a switch and played them as bonus stages. Since the split, those belong to Community Edition, and this port plays the original 20. See [Bonus stages](bonus-stages.md). ## By the numbers @@ -65,5 +65,5 @@ timeline | Research claims checked | 111 for the rules, 94 for the presentation | | Recorded inputs replayed in the port | 2,756 over 20 stages, none refused | | Fidelity bugs a review found in the first M3 build | 12, all fixed | -| Tests | 164 | +| Tests | 164 before the split; now 87 here and 202 in the engine | | Release archives | 4 (Windows, Linux, and Apple Silicon and Intel macOS), about 37 MB each | diff --git a/docs/keeping-it-honest.md b/docs/keeping-it-honest.md index b99908a..5fe2e2b 100644 --- a/docs/keeping-it-honest.md +++ b/docs/keeping-it-honest.md @@ -40,7 +40,7 @@ flowchart LR ```mermaid flowchart LR subgraph ci["Every push"] - guard["No game data"] --> build["Build
warnings are errors"] --> tests["Tests"] --> format["dotnet format"] + engine["Engine submodule
is checked out"] --> guard["No game data"] --> build["Build
warnings are errors"] --> tests["Tests"] --> format["dotnet format"] end subgraph release["A v* tag"] publish["Publish 4 archives
from Linux"] --> start["Start each one
on Windows, Linux, macOS"] --> gh["GitHub release"] @@ -48,7 +48,9 @@ flowchart LR ``` - **No game data:** `scripts/check-no-game-data.sh` fails on Unity bundles, FMOD banks, IL2CPP metadata, Atlus flow scripts, stage files outside the tests, and decompiler output. -- Its positive control, `check-no-game-data.test.sh`, plants each of those in a scratch repo and expects the check to fail. A check for something's absence needs proof that it can fail. +- Its positive control, `check-no-game-data.test.sh`, plants each of those in a scratch repo and expects the check to fail. A check for something's absence needs proof that it can fail. Every scratch repo also holds a submodule, which the check must skip, since the engine repo runs the same check on its own files. +- **The engine is a pinned submodule.** CI checks out `external/puzzle-boy-engine` at the pinned commit and stops with a clear message if it's missing, rather than failing later in restore. +- **The original's scope.** Tests prove the game loads only the original's 20 stage files, never references `Board.Switch` (a scan of its metadata, with `Board.Step` as the positive control) and reads no switch or stage-skip key. - **Docs are part of the build.** Every public type and member needs an XML doc comment, or the build fails (CS1591). - **Releases** are self-contained, so players don't need .NET. CI has no game install, so each archive must start on its own OS and reach the "there's no game here" message before the release is created. diff --git a/docs/rules.md b/docs/rules.md index bd67700..c2bf1ba 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -3,7 +3,7 @@ > [!TIP] > **TL;DR:** A stage is an 828-byte file with a 20×20 grid. Each move is checked in a fixed order: the goal, then bumps, then whatever is in the next cell. Blocks move one cell as a group, fall when every cell is over a hole, and never push other blocks. Swivels turn a quarter. Undo remembers the last 30 pushes and turns, and plain walking is never recorded. -**Read time:** 5 minutes · **Code:** `src/PuzzleBoy.Rules` +**Read time:** 5 minutes · **Code:** `PuzzleBoy.Rules`, in the engine submodule `external/puzzle-boy-engine` ## The stage file @@ -78,7 +78,7 @@ flowchart TD - A character that reaches the goal while others are still out is hidden, and control passes to the next visible slot, in ascending order. - The last one to arrive clears the stage. Input locks, the undo history is wiped, and the next stage loads. -- **The switch is the port's addition.** The original only passes control on a goal arrival. The C key passes it the same way at any time, so the unused multi-character stages can be finished. See [Bonus stages](bonus-stages.md). +- **The switch is the engine's addition, and this port doesn't use it.** The original only passes control on a goal arrival. `Board.Switch` passes it the same way at any time, so Community Edition can finish the unused multi-character stages. This port, like the original, has no switch key. See [Bonus stages](bonus-stages.md). ## Undo and retry @@ -96,7 +96,7 @@ None of these can show up in a stage the original plays. | The two-cell swivel step compares group ids only | Counts only the swivel's own cells | Avoids matching floor if a swivel used group 0 | | A swivel turn past the grid's edge would crash | Refuses the turn | No shipped swivel touches the edge | | Undo stores only what changed | Undo stores a full copy | Simpler, with the same results | -| No way to switch characters | C switches | Six multi-character stages need it | +| No way to switch characters | The engine has `Board.Switch`; this port never calls it | Community Edition's multi-character stages need it | ## Rules as drawings @@ -113,6 +113,8 @@ Only character 2 can push `a` down into the hole, and character 1 can't get to t ## Where to look +All in the engine submodule, `external/puzzle-boy-engine`: + - `src/PuzzleBoy.Rules/Stage.cs`, `Cell` and `CellKind`: the file format. - `src/PuzzleBoy.Rules/Board.cs`: every rule above. -- `tests/PuzzleBoy.Tests/*Tests.cs`: one drawing per rule. `Ascii.cs` explains the legend. +- `tests/PuzzleBoy.Rules.Tests/*Tests.cs`: one drawing per rule. `Ascii.cs` explains the legend. diff --git a/docs/timing-and-camera.md b/docs/timing-and-camera.md index 7fcb038..9f24318 100644 --- a/docs/timing-and-camera.md +++ b/docs/timing-and-camera.md @@ -54,7 +54,7 @@ stateDiagram-v2 2. ○, overhead view (V) 3. The directions: up, down, left, right. The first one held wins. 4. L1 and R1, turning the view (Q and E) -5. The port's own keys: switch (C), then □ for the menu (F) +5. □ for the menu (F) ## The camera From e5923d2a2bd25d7b1ede6d06872b79ca98f4e1aa Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:34:03 -0500 Subject: [PATCH 06/14] docs(roadmap): keep M5 open until CI passes on GitHub --- ROADMAP.md | 3 ++- docs/keeping-it-honest.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bf72de5..94d6763 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -245,7 +245,7 @@ The stage files the original ships but never plays, after stage 20. See the deci **Done when** every bonus stage that ships has a solution that clears it in the port, and the switch's rules have tests. -## M5: Split into three repos (done 2026.10.15) +## M5: Split into three repos (local gates pass 2026.10.15; CI waits on the engine's publication) This repo goes back to being a faithful preservation port of the original 20 stages. The rules move to a shared engine, and everything the original never had moves to a standalone Community Edition that needs no Nocturne. @@ -262,6 +262,7 @@ This repo goes back to being a faithful preservation port of the original 20 sta - `Hud.cs` is back to v0.1.0, and `PuzzleBoyGame.cs` differs from v0.1.0 only by the key table and the removed skip. - [x] Update the docs to tell the preservation port apart from Community Edition. - README, AGENTS.md and the docs. `docs/bonus-stages.md` stays as history, with a note that bonus play moved to Community Edition. +- [ ] CI passes on GitHub once the engine is published. **Done when** this repo builds and tests against the pinned engine, plays exactly the 20 original stages, and passes every CI gate including the no-game-data check. diff --git a/docs/keeping-it-honest.md b/docs/keeping-it-honest.md index 5fe2e2b..e6822db 100644 --- a/docs/keeping-it-honest.md +++ b/docs/keeping-it-honest.md @@ -49,7 +49,7 @@ flowchart LR - **No game data:** `scripts/check-no-game-data.sh` fails on Unity bundles, FMOD banks, IL2CPP metadata, Atlus flow scripts, stage files outside the tests, and decompiler output. - Its positive control, `check-no-game-data.test.sh`, plants each of those in a scratch repo and expects the check to fail. A check for something's absence needs proof that it can fail. Every scratch repo also holds a submodule, which the check must skip, since the engine repo runs the same check on its own files. -- **The engine is a pinned submodule.** CI checks out `external/puzzle-boy-engine` at the pinned commit and stops with a clear message if it's missing, rather than failing later in restore. +- **The engine is a pinned submodule.** CI checks out `external/puzzle-boy-engine` at the pinned commit and stops with a clear message if it's missing, rather than failing later in restore. Until the engine is published on GitHub, that checkout fails, so CI is blocked; local clones override the URL. - **The original's scope.** Tests prove the game loads only the original's 20 stage files, never references `Board.Switch` (a scan of its metadata, with `Board.Step` as the positive control) and reads no switch or stage-skip key. - **Docs are part of the build.** Every public type and member needs an XML doc comment, or the build fails (CS1591). - **Releases** are self-contained, so players don't need .NET. CI has no game install, so each archive must start on its own OS and reach the "there's no game here" message before the release is created. From 0c76985c517b31a4b13fa2647d5c378b9cba4154 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:34:23 -0500 Subject: [PATCH 07/14] docs: narrow the key claims to what the tests check --- ROADMAP.md | 4 ++-- docs/keeping-it-honest.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 94d6763..d628ed2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ Puzzle Boy is a standalone, cross-platform rebuild of the Puzzle Boy minigame fr ## Decisions (2026.10.15) - **This port plays the original's 20 stages, and nothing else.** `OriginalStageOrder` replaces `PlayOrder.Files` in the game, and `GameAssets` loads only those files. The C switch, the switch's controls bar and PageUp/PageDown go. PageUp/PageDown predate M4 as a developer shortcut in v0.1.0, but the original has no stage skip, so it goes too. After stage 20 the board stays cleared, as in v0.1.0. -- **Every key read as a press is in one table.** `PuzzleBoyGame.Presses` lists them, and `Pressed` throws on any other key. A test pins the table, so a switch or skip key can't come back unseen. Held keys (the directions and Escape) are read directly, as before. +- **Every key read as a press is in one table.** `PuzzleBoyGame.Presses` lists them, and `Pressed` throws on any other key. A test pins the table and the direction table. Held keys (the directions and Escape) are read directly, as before, so a key read that way is outside what the tests see. - **The game-data guard skips submodules.** `git ls-files` lists the engine submodule as a directory, which stopped the check. The engine repo runs the same check on its own files. The positive control now adds a submodule to every scratch repo. - **CI needs the engine on GitHub.** `.gitmodules` names `https://github.com/hazeliscoding/puzzle-boy-engine.git`, which isn't published yet, so CI here can't check out until it is. Local clones override the URL in `.git/config` (AGENTS.md, Commands). @@ -258,7 +258,7 @@ This repo goes back to being a faithful preservation port of the original 20 sta - The engine holds `PuzzleBoy.Rules` and 86 of the 164 tests (the Cell, Goal, LastMove, Pushing, Stage, Switch, Swivel, Undo and Walking tests), unchanged but for their namespace. `PlayOrder` isn't in the engine. The engine adds a stage authoring API and has 202 tests. - [x] Depend on the pinned engine and restore the original 20-stage campaign: no bonus stages, no switch key, no stage skipping. - The submodule is pinned at engine `99eaf06`, and `src/PuzzleBoy.Rules` is gone. 78 tests stayed here after the move. The bonus-order test gave way to 10 new ones, for 87. - - Tests prove the order is exactly the 20 documented files, each once, with none of the 23 bonus files; that `GameAssets` loads only those 20 from a bundle holding all 50; that the game never references `Board.Switch` (a metadata scan, with `Board.Step` as the positive control); and that no switch or skip key is read. Adding a `Board.Switch` call or a PageDown press fails them. + - Tests prove the order is exactly the 20 documented files, each once, with none of the 23 bonus files; that `GameAssets` loads only those 20 from a bundle holding all 50; that the game never references `Board.Switch` (a metadata scan, with `Board.Step` as the positive control); and that no switch or skip key is in the table of pressed keys or the direction table. Adding a `Board.Switch` call, or adding PageDown to either table, fails them. - `Hud.cs` is back to v0.1.0, and `PuzzleBoyGame.cs` differs from v0.1.0 only by the key table and the removed skip. - [x] Update the docs to tell the preservation port apart from Community Edition. - README, AGENTS.md and the docs. `docs/bonus-stages.md` stays as history, with a note that bonus play moved to Community Edition. diff --git a/docs/keeping-it-honest.md b/docs/keeping-it-honest.md index e6822db..148513d 100644 --- a/docs/keeping-it-honest.md +++ b/docs/keeping-it-honest.md @@ -50,7 +50,7 @@ flowchart LR - **No game data:** `scripts/check-no-game-data.sh` fails on Unity bundles, FMOD banks, IL2CPP metadata, Atlus flow scripts, stage files outside the tests, and decompiler output. - Its positive control, `check-no-game-data.test.sh`, plants each of those in a scratch repo and expects the check to fail. A check for something's absence needs proof that it can fail. Every scratch repo also holds a submodule, which the check must skip, since the engine repo runs the same check on its own files. - **The engine is a pinned submodule.** CI checks out `external/puzzle-boy-engine` at the pinned commit and stops with a clear message if it's missing, rather than failing later in restore. Until the engine is published on GitHub, that checkout fails, so CI is blocked; local clones override the URL. -- **The original's scope.** Tests prove the game loads only the original's 20 stage files, never references `Board.Switch` (a scan of its metadata, with `Board.Step` as the positive control) and reads no switch or stage-skip key. +- **The original's scope.** Tests prove the game loads only the original's 20 stage files, never references `Board.Switch` (a scan of its metadata, with `Board.Step` as the positive control) and has no switch or stage-skip key in its table of pressed keys or its direction table. - **Docs are part of the build.** Every public type and member needs an XML doc comment, or the build fails (CS1591). - **Releases** are self-contained, so players don't need .NET. CI has no game install, so each archive must start on its own OS and reach the "there's no game here" message before the release is created. From c3b0382ad9ecfa2ade8b5bf21bad51325599dbc1 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 00:34:37 -0500 Subject: [PATCH 08/14] docs(roadmap): keep the Bonus menu item observation --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index d628ed2..8c2ebe3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -278,7 +278,7 @@ This repo goes back to being a faithful preservation port of the original 20 sta - Shipping or downloading any Atlus asset. - Circumventing Denuvo, patching the original game or modding it. - Nocturne's other minigames. -- Anything the original lacks: bonus stages, a switch key, stage skipping, a stage editor and custom stage packs. They belong to Community Edition (M5), along with the seven unsettled stage files. +- Anything the original lacks: bonus stages, a switch key, stage skipping, a stage editor, custom stage packs, and the original's unused fifth □ menu item, Bonus, whose label has no text in the game's tables. They belong to Community Edition (M5), along with the seven unsettled stage files. ## How we'll know it works From a22ac2ee7cb7e0193d66983c0537fd541fc89cb9 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 09:20:01 -0500 Subject: [PATCH 09/14] build: bump engine to 03ee710 - refactor: move bundle reading into PuzzleBoy.Nocturne - refactor: move board and character rendering into PuzzleBoy.Rendering - docs: describe the shared presentation libraries --- external/puzzle-boy-engine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/puzzle-boy-engine b/external/puzzle-boy-engine index 99eaf06..03ee710 160000 --- a/external/puzzle-boy-engine +++ b/external/puzzle-boy-engine @@ -1 +1 @@ -Subproject commit 99eaf06246540591a91802385e8fc7aeb7126467 +Subproject commit 03ee71082e1ad23877e183bd0b9fe596ebf0e1ca From bcc29ae05711e60835577a1a917539a5f7f6d263 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 09:20:53 -0500 Subject: [PATCH 10/14] refactor: use the shared rendering and asset libraries The install reader, texture and mesh decoders, board and character drawing, camera, move timing and sounds now come from the engine's PuzzleBoy.Nocturne and PuzzleBoy.Rendering, which hold the same code with only namespace, visibility and citation changes. Their tests moved with them; the Board.Switch scan now also covers PuzzleBoy.Rendering. The game drops its direct AssetsTools.NET, Fmod5Sharp and NVorbis references, which now come through PuzzleBoy.Nocturne. --- PuzzleBoy.slnx | 2 + src/PuzzleBoy/BoardView.cs | 345 ------------------ src/PuzzleBoy/CharacterModel.cs | 105 ------ src/PuzzleBoy/CharacterView.cs | 218 ----------- src/PuzzleBoy/GameAssets.cs | 1 + src/PuzzleBoy/GameInstall.Character.cs | 260 ------------- src/PuzzleBoy/GameInstall.Text.cs | 107 ------ src/PuzzleBoy/GameInstall.cs | 224 ------------ src/PuzzleBoy/Hud.cs | 2 + src/PuzzleBoy/MoveAnimation.cs | 68 ---- src/PuzzleBoy/Program.cs | 1 + src/PuzzleBoy/PuzzleBoy.csproj | 7 +- src/PuzzleBoy/PuzzleBoyGame.cs | 1 + src/PuzzleBoy/Sounds.cs | 62 ---- src/PuzzleBoy/TextRenderer.cs | 53 +-- src/PuzzleBoy/TextureDecoder.cs | 66 ---- src/PuzzleBoy/UnityFormats.cs | 219 ----------- src/PuzzleBoy/ViewCamera.cs | 160 -------- tests/PuzzleBoy.Tests/CharacterModelTests.cs | 69 ---- tests/PuzzleBoy.Tests/ControlsTests.cs | 1 + tests/PuzzleBoy.Tests/GameInstallTests.cs | 33 -- .../PuzzleBoy.Tests/PreservationScopeTests.cs | 31 +- tests/PuzzleBoy.Tests/SoundDataTests.cs | 10 - tests/PuzzleBoy.Tests/TextRendererTests.cs | 11 +- tests/PuzzleBoy.Tests/TextureDecoderTests.cs | 44 --- tests/PuzzleBoy.Tests/TimingTests.cs | 161 -------- tests/PuzzleBoy.Tests/UnityFormatsTests.cs | 119 ------ 27 files changed, 32 insertions(+), 2348 deletions(-) delete mode 100644 src/PuzzleBoy/BoardView.cs delete mode 100644 src/PuzzleBoy/CharacterModel.cs delete mode 100644 src/PuzzleBoy/CharacterView.cs delete mode 100644 src/PuzzleBoy/GameInstall.Character.cs delete mode 100644 src/PuzzleBoy/GameInstall.Text.cs delete mode 100644 src/PuzzleBoy/GameInstall.cs delete mode 100644 src/PuzzleBoy/MoveAnimation.cs delete mode 100644 src/PuzzleBoy/Sounds.cs delete mode 100644 src/PuzzleBoy/TextureDecoder.cs delete mode 100644 src/PuzzleBoy/UnityFormats.cs delete mode 100644 src/PuzzleBoy/ViewCamera.cs delete mode 100644 tests/PuzzleBoy.Tests/CharacterModelTests.cs delete mode 100644 tests/PuzzleBoy.Tests/GameInstallTests.cs delete mode 100644 tests/PuzzleBoy.Tests/SoundDataTests.cs delete mode 100644 tests/PuzzleBoy.Tests/TextureDecoderTests.cs delete mode 100644 tests/PuzzleBoy.Tests/TimingTests.cs delete mode 100644 tests/PuzzleBoy.Tests/UnityFormatsTests.cs diff --git a/PuzzleBoy.slnx b/PuzzleBoy.slnx index f93bcb9..454e141 100644 --- a/PuzzleBoy.slnx +++ b/PuzzleBoy.slnx @@ -1,5 +1,7 @@ + + diff --git a/src/PuzzleBoy/BoardView.cs b/src/PuzzleBoy/BoardView.cs deleted file mode 100644 index d6f8fd6..0000000 --- a/src/PuzzleBoy/BoardView.cs +++ /dev/null @@ -1,345 +0,0 @@ -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using PuzzleBoy.Rules; - -namespace PuzzleBoy; - -///

-/// Draws a board the way the original's pbDraw builds it (ROADMAP.md, "M3 findings": board): unlit and textured from -/// the sample1 atlas, glass walls, white outlines, a translucent goal and the BG_pzl backdrop. -/// Distances are in cells; the original's 40 units are 1 here. -/// -internal sealed class BoardView : IDisposable -{ - // Atlas tiles as the original's Unity UV rectangles (u0, u1, v0, v1). The atlas keeps Unity's row order on the - // GPU, so the same numbers sample the same texels. - private static readonly Vector4 BlockTile = new(0, 0.25f, 0, 0.5f); - private static readonly Vector4 FloorTile = new(0, 0.25f, 0.5f, 1); - private static readonly Vector4 WallTile = new(0.75f, 1, 0, 1); - private static readonly Vector4 SwivelTile = new(0.25f, 0.5f, 0.25f, 0.5f); - private static readonly Vector4 DomeTile = new(0.5f, 0.75f, 0.5f, 1); - - // Glass panels are 39 units wide and 50 tall; outlines are lines 1.2 units wide. - private const float PanelHalf = 19.5f / 40; - private const float PanelHeight = 50f / 40; - private const float LineWidth = 1.2f / 40; - - private static readonly Color Glass = new(1f, 1f, 1f, 0.2f); - private static readonly Color WallLine = new(128, 128, 128); - - // The original writes (0, 255, 200, 0.3) as float colors; see the goal's blend in Draw. - private static readonly Color GoalColor = new(0f, 1f, 1f, 0.3f); - - private readonly GraphicsDevice _device; - private readonly Texture2D _atlas; - private readonly Texture2D _background; - private readonly Texture2D _white; - private readonly BasicEffect _effect; - private readonly SpriteBatch _sprites; - private readonly CharacterView _characters; - private readonly List _tiles = []; - private readonly List _solid = []; - private readonly List _glass = []; - private readonly List _clear = []; - private Vector3 _eye; - - /// Uploads the atlas, the backdrop and the character's textures. - public BoardView(GraphicsDevice device, TextureData atlas, TextureData background, CharacterData character) - { - _device = device; - _atlas = Upload(device, atlas); - _background = Upload(device, background); - _white = new Texture2D(device, 1, 1); - _white.SetData([Color.White]); - _effect = new BasicEffect(device) { TextureEnabled = true, VertexColorEnabled = true, LightingEnabled = false }; - _sprites = new SpriteBatch(device); - _characters = new CharacterView(device, character); - } - - /// Draws with 's step in progress. - /// The board to draw. - /// The step being played, if any. - /// The camera's view matrix. - /// The camera's projection matrix. - /// Ticks since the game started, for the goal's spin of 1° a tick. - /// The characters to draw, already posed. - /// The camera's yaw in degrees (), which the character's light follows. - public void Draw(Board board, MoveAnimation? animation, Matrix view, Matrix projection, int ticks, IReadOnlyList characters, float viewYaw) - { - _eye = Matrix.Invert(view).Translation; - _tiles.Clear(); - _solid.Clear(); - _glass.Clear(); - _clear.Clear(); - - var moving = Moving(animation); - for (var y = 0; y < Stage.Height; y++) - { - for (var x = 0; x < Stage.Width; x++) - { - AddCell(board, x, y, moving, animation); - } - } - - AddMovingParts(board, animation, moving); - - // The goal is a 20-unit cube centered 20 units up, spinning about its own axis. - // Unity's Rotate((0, 1, 0)) turns clockwise seen from above; the port's flipped z needs the opposite sign. - var spin = Matrix.CreateRotationY(MathHelper.ToRadians(-ticks)); - var (gx, gy) = board.Goal; - var goal = Matrix.CreateTranslation(-gx, -0.5f, gy) * spin * Matrix.CreateTranslation(gx, 0.5f, -gy); - AddBox(_clear, gx, gy, 0.25f, 0.75f, 0.25f, GoalColor, goal, Tiles(Vector4.Zero)); - - DrawBackdrop(); - _effect.View = view; - _effect.Projection = projection; - _device.RasterizerState = RasterizerState.CullNone; - _device.SamplerStates[0] = SamplerState.LinearWrap; - _device.DepthStencilState = DepthStencilState.Default; - _device.BlendState = BlendState.Opaque; - Submit(_tiles, _atlas); - Submit(_solid, _white); - _characters.DrawModels(characters, view, projection, viewYaw); - _characters.DrawShadowsAndGlows(characters, view, projection); - - // Glass and the goal blend over everything opaque and don't hide each other. - _device.DepthStencilState = DepthStencilState.DepthRead; - _device.BlendState = BlendState.NonPremultiplied; - _device.RasterizerState = RasterizerState.CullNone; - _device.SamplerStates[0] = SamplerState.LinearWrap; - Submit(_glass, _atlas); - - // The original's goal color is (0, 255, 200) at 30% opacity: blending 0.3 x 255 saturates, leaving cyan - // plus 70% of what's behind. Adding the color unscaled does the same. - _device.BlendState = BlendState.AlphaBlend; - Submit(_clear, _white); - } - - /// - public void Dispose() - { - _atlas.Dispose(); - _background.Dispose(); - _white.Dispose(); - _effect.Dispose(); - _sprites.Dispose(); - _characters.Dispose(); - } - - /// The cells of the group the current step moves, drawn on their own while it plays. - private static HashSet<(int X, int Y)> Moving(MoveAnimation? animation) => - animation is { } a && a.Move.Result != StepResult.Walked && (!a.Move.Fell || a.Falling) ? [.. a.Move.Landings] : []; - - private void AddCell(Board board, int x, int y, HashSet<(int X, int Y)> moving, MoveAnimation? animation) - { - var cell = board[x, y]; - var type = cell >> 13; - - // Every cell but a hole or a plain wall stands on a floor cube from -1 to 0. A fallen block's holes - // stay open until it has sunk. - var opening = moving.Contains((x, y)) && animation!.Move.Fell; - if ((cell & Cell.HoleFlag) == 0 && type != 1 && !opening) - { - AddBox(_tiles, x, y, -1, 0, 0.5f, Color.White, Matrix.Identity, Tiles(FloorTile)); - } - - if (type == 1) - { - AddWall(board, x, y); - } - else if (type is >= 2 and <= 5 && !moving.Contains((x, y))) - { - AddPart(board, x, y, Cell.KindOf(cell), Cell.GroupOf(cell), Matrix.Identity, outlined: true); - } - } - - /// A wall's glass faces toward each open neighbour: a floor-height band and a panel above it, outlined in grey. - private void AddWall(Board board, int x, int y) - { - foreach (var (dx, dy) in Sides) - { - var (nx, ny) = (x + dx, y + dy); - if (nx is < 0 or >= Stage.Width || ny is < 0 or >= Stage.Height || board[nx, ny] >> 13 == 1) - { - continue; - } - - var normal = new Vector3(dx, 0, -dy); - var right = Vector3.Cross(-normal, Vector3.Up); - var center = new Vector3(x, 0, -y); - AddFace(_glass, center + (normal * 0.5f), right * 0.5f, -1, 0, Glass, FloorTile); - var panel = center + (normal * PanelHalf); - AddFace(_glass, panel, right * PanelHalf, 0, PanelHeight, Glass, WallTile); - - var (a, b) = (panel - (right * PanelHalf), panel + (right * PanelHalf)); - var up = Vector3.Up * PanelHeight; - AddLine(a, b, WallLine); - AddLine(b, b + up, WallLine); - AddLine(b + up, a + up, WallLine); - AddLine(a + up, a, WallLine); - } - } - - /// The pushed or turned group, slid, turned or sunk back by the part of the move still to come. - private void AddMovingParts(Board board, MoveAnimation? animation, HashSet<(int X, int Y)> moving) - { - if (animation is null || moving.Count == 0) - { - return; - } - - var move = animation.Move; - var left = 1 - animation.Slide; - Matrix transform; - if (move.Result == StepResult.Pushed) - { - var (dx, dy) = move.Direction.Offset(); - transform = Matrix.CreateTranslation(-dx * left, -animation.Sink, dy * left); - } - else - { - // Turn back about the center, counterclockwise for a clockwise turn (x right, y up). - var (cx, cy) = move.Center; - var angle = -move.Turn * MathHelper.PiOver2 * left; - transform = Matrix.CreateTranslation(-cx, 0, cy) * Matrix.CreateRotationY(angle) * Matrix.CreateTranslation(cx, 0, -cy); - } - - foreach (var (x, y) in move.Landings) - { - // After a fall the landings are floor, so the sinking block is drawn from the move. - var kind = move.Fell ? CellKind.Block : Cell.KindOf(board[x, y]); - AddPart(board, x, y, kind, move.Group, transform, outlined: !move.Fell); - } - } - - /// A block or swivel cube from 0 to 1, with white outlines along its top edges where its group ends. - private void AddPart(Board board, int x, int y, CellKind kind, int group, Matrix transform, bool outlined) - { - var tiles = kind switch - { - CellKind.Block => Tiles(BlockTile), - CellKind.SwivelCenter => Tiles(SwivelTile, top: DomeTile), - _ => Tiles(SwivelTile), - }; - AddBox(_tiles, x, y, 0, 1, 0.5f, Color.White, transform, tiles); - if (!outlined) - { - return; - } - - // The original's outline sits on the top edge on each side whose neighbour isn't the same group. - foreach (var (dx, dy) in Sides) - { - var (nx, ny) = (x + dx, y + dy); - var inGroup = nx is >= 0 and < Stage.Width && ny is >= 0 and < Stage.Height - && board[nx, ny] >> 13 is >= 2 and <= 5 && Cell.GroupOf(board[nx, ny]) == group; - if (!inGroup) - { - var edge = new Vector3(x + (dx * 0.5f), 1, -y - (dy * 0.5f)); - var along = new Vector3(dy * 0.5f, 0, dx * 0.5f); - AddLine(Vector3.Transform(edge - along, transform), Vector3.Transform(edge + along, transform), Color.White); - } - } - } - - /// A box over cell (, ) from height to . - private static void AddBox(List into, float x, float y, float bottom, float top, float half, Color color, Matrix transform, Vector4[] tiles) - { - var center = new Vector3(x, 0, -y); - var start = into.Count; - for (var i = 0; i < 4; i++) - { - var normal = new Vector3(Sides[i].X, 0, -Sides[i].Y); - AddFace(into, center + (normal * half), Vector3.Cross(-normal, Vector3.Up) * half, bottom, top, color, tiles[i]); - } - - // Top and bottom: the top's texture runs up toward grid +y, away from the default camera. - var (east, north) = (Vector3.UnitX * half, -Vector3.UnitZ * half); - AddQuad(into, center + (Vector3.Up * top) - east - north, east * 2, north * 2, color, tiles[4]); - AddQuad(into, center + (Vector3.Up * bottom) - east + north, east * 2, -north * 2, color, tiles[5]); - for (var i = start; i < into.Count; i++) - { - var v = into[i]; - into[i] = v with { Position = Vector3.Transform(v.Position, transform) }; - } - } - - /// A vertical face centered on , spanning ± and to . - private static void AddFace(List into, Vector3 center, Vector3 right, float bottom, float top, Color color, Vector4 tile) - { - var corner = center - right + (Vector3.Up * bottom); - AddQuad(into, corner, right * 2, Vector3.Up * (top - bottom), color, tile); - } - - /// A quad from along and , mapped to with v up the face. - private static void AddQuad(List into, Vector3 corner, Vector3 u, Vector3 v, Color color, Vector4 tile) - { - var a = new VertexPositionColorTexture(corner, color, new Vector2(tile.X, tile.Z)); - var b = new VertexPositionColorTexture(corner + u, color, new Vector2(tile.Y, tile.Z)); - var c = new VertexPositionColorTexture(corner + v, color, new Vector2(tile.X, tile.W)); - var d = new VertexPositionColorTexture(corner + u + v, color, new Vector2(tile.Y, tile.W)); - into.AddRange([a, b, c, b, d, c]); - } - - /// A line drawn as a thin quad turned to face the camera, like Unity's LineRenderer. - private void AddLine(Vector3 a, Vector3 b, Color color) - { - var side = Vector3.Cross(b - a, _eye - ((a + b) / 2)); - if (side.LengthSquared() < 1e-12f) - { - return; - } - - side = Vector3.Normalize(side) * (LineWidth / 2); - AddQuad(_solid, a - side, b - a, side * 2, color, Vector4.Zero); - } - - private void DrawBackdrop() - { - // The original's backdrop is a plane the camera code keeps facing the view, so it reads as a still image. - // MEASURED by aligning BG_pzl with HD footage (correlation 0.95): it shows 1.55 times larger than fitting - // a 16:9 screen, with the screen's center over image pixel (960, 372), counted from the top. - var viewport = _device.Viewport; - var scale = 1.55f * viewport.Height / _background.Height; - var origin = new Vector2((viewport.Width / 2f) - (960 * scale), (viewport.Height / 2f) - (372 * scale)); - _sprites.Begin(SpriteSortMode.Deferred, BlendState.Opaque, SamplerState.LinearClamp, DepthStencilState.None, RasterizerState.CullNone); - _sprites.Draw(_background, origin, null, Color.White, 0, Vector2.Zero, scale, SpriteEffects.FlipVertically, 0); - _sprites.End(); - } - - private void Submit(List vertices, Texture2D texture) - { - if (vertices.Count == 0) - { - return; - } - - _effect.Texture = texture; - _effect.World = Matrix.Identity; - foreach (var pass in _effect.CurrentTechnique.Passes) - { - pass.Apply(); - _device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices.ToArray(), 0, vertices.Count / 3); - } - } - - /// Uploads every mip level, keeping Unity's row order so Unity's UVs sample the same texels. - internal static Texture2D Upload(GraphicsDevice device, TextureData data) - { - var full = (int)Math.Log2(Math.Max(data.Width, data.Height)) + 1; - var texture = new Texture2D(device, data.Width, data.Height, data.Levels.Count == full, SurfaceFormat.Color); - for (var level = 0; level < (data.Levels.Count == full ? full : 1); level++) - { - texture.SetData(level, null, data.Levels[level], 0, data.Levels[level].Length); - } - - return texture; - } - - /// One tile per face: the four sides in order, then the top and the bottom. - private static Vector4[] Tiles(Vector4 tile, Vector4? top = null) => [tile, tile, tile, tile, top ?? tile, tile]; - - // +y, +x, -y, -x: the original's direction order. - private static readonly (int X, int Y)[] Sides = [(0, 1), (1, 0), (0, -1), (-1, 0)]; - -} diff --git a/src/PuzzleBoy/CharacterModel.cs b/src/PuzzleBoy/CharacterModel.cs deleted file mode 100644 index 0dd5089..0000000 --- a/src/PuzzleBoy/CharacterModel.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Microsoft.Xna.Framework; - -namespace PuzzleBoy; - -/// -/// Poses and skins Jack-o'-Lantern each tick, as re/m3-jack.md describes: the idle clip on the bones, the original's -/// placement and facing, then CPU skinning into the port's space (cells, grid +y toward -z). -/// -/// The model read from the player's install. -internal sealed class CharacterModel(CharacterData data) -{ - // The original drops its model a quarter cell (10 units) below the character's position. - private const float Height = -0.25f; - - private readonly Matrix[] _world = new Matrix[data.Nodes.Length]; - private readonly Vector3[] _positions = new Vector3[data.Nodes.Length]; - private readonly Quaternion[] _rotations = new Quaternion[data.Nodes.Length]; - private readonly Vector3[] _scales = new Vector3[data.Nodes.Length]; - - /// The model. - public CharacterData Data => data; - - /// Node 's world matrix from the last , in the port's space. - public Matrix World(int node) => _world[node]; - - /// - /// Poses the idle clip at (it loops) and places the model on grid cell (, - /// ), which may be fractional mid-step, turned degrees: 0 faces grid +y, - /// 90 grid +x. - /// - public void Pose(int tick, float x, float y, float yaw) - { - for (var n = 0; n < data.Nodes.Length; n++) - { - (_positions[n], _rotations[n], _scales[n]) = (data.Nodes[n].Position, data.Nodes[n].Rotation, data.Nodes[n].Scale); - } - - // An integer phase keeps the loop seam exact. - var time = (tick % data.LoopTicks) / 30f; - foreach (var (node, attribute, first) in data.Bindings) - { - var c = data.Curves; - switch (attribute) - { - case 1: - _positions[node] = new Vector3(c.Value(first, time), c.Value(first + 1, time), c.Value(first + 2, time)); - break; - case 2: - _rotations[node] = Quaternion.Normalize(new Quaternion(c.Value(first, time), c.Value(first + 1, time), c.Value(first + 2, time), c.Value(first + 3, time))); - break; - default: - _scales[node] = new Vector3(c.Value(first, time), c.Value(first + 1, time), c.Value(first + 2, time)); - break; - } - } - - // Unity's Euler(-90, yaw, 0) applies x, then y, at the original's scale of 40, which is 1 cell per model unit - // here. The final mirror of z turns Unity's left-handed world into the port's. - var placed = Matrix.CreateRotationX(MathHelper.ToRadians(-90)) * Matrix.CreateRotationY(MathHelper.ToRadians(yaw)) - * Matrix.CreateTranslation(x, Height, y) * Matrix.CreateScale(1, 1, -1); - for (var n = 0; n < data.Nodes.Length; n++) - { - var local = Matrix.CreateScale(_scales[n]) * Matrix.CreateFromQuaternion(_rotations[n]) * Matrix.CreateTranslation(_positions[n]); - var parent = data.Nodes[n].Parent; - _world[n] = n == data.Placed ? placed : parent < 0 ? local : local * _world[parent]; - } - } - - /// Mesh 's vertices and normals from the last , into arrays its size. - public void Skin(int mesh, Vector3[] positions, Vector3[] normals) - { - var m = data.Meshes[mesh]; - if (m.Bones.Length == 0) - { - var world = _world[m.Node]; - for (var v = 0; v < m.Positions.Length; v++) - { - positions[v] = Vector3.Transform(m.Positions[v], world); - normals[v] = Vector3.Normalize(Vector3.TransformNormal(m.Normals[v], world)); - } - - return; - } - - // Each bone's skin matrix takes a vertex from the bind pose to the bone's posed world. - var skin = new Matrix[m.Bones.Length]; - for (var b = 0; b < skin.Length; b++) - { - skin[b] = m.BindPoses[b] * _world[m.Bones[b]]; - } - - for (var v = 0; v < m.Positions.Length; v++) - { - var (position, normal) = (Vector3.Zero, Vector3.Zero); - foreach (var (bone, weight) in m.Influences[v]) - { - position += Vector3.Transform(m.Positions[v], skin[bone]) * weight; - normal += Vector3.TransformNormal(m.Normals[v], skin[bone]) * weight; - } - - positions[v] = position; - normals[v] = Vector3.Normalize(normal); - } - } -} diff --git a/src/PuzzleBoy/CharacterView.cs b/src/PuzzleBoy/CharacterView.cs deleted file mode 100644 index 0f78c5b..0000000 --- a/src/PuzzleBoy/CharacterView.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Runtime.InteropServices; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; - -namespace PuzzleBoy; - -/// -/// Draws Jack-o'-Lantern the way re/m3-jack.md fits the HD look with built-in effects: each material's texture times a -/// toon ramp from tone2, no outline, a shadow that subtracts from the floor and a generated lantern glow. -/// -internal sealed class CharacterView : IDisposable -{ - // The ramp is tone2's band 4, Unity row 36 from the bottom. INFERRED from footage, like the light and the 1.1x - // brightness: DualTextureEffect doubles texture x ramp, so 0.55 gives 1.1. - private const int RampRow = 36; - private const float Brightness = 0.55f; - - // The light comes from screen-left, about 24 degrees up and toward the camera, at view yaw 0. - private static readonly Vector3 Light = Vector3.Normalize(new Vector3(-0.8f, 0.4f, 0.4f)); - - // INFERRED: the footage's shadow takes 77-92 levels off the floor; the material's own 110 at 80% fits. - private const float ShadowStrength = 0.8f; - - // The glow: a camera-facing square 1.5 cells wide, adding up to (120, 84, 40) at its center. - private const float GlowHalf = 0.75f; - - private static readonly BlendState Subtract = new() - { - ColorBlendFunction = BlendFunction.ReverseSubtract, - ColorSourceBlend = Blend.SourceAlpha, - ColorDestinationBlend = Blend.One, - AlphaSourceBlend = Blend.Zero, - AlphaDestinationBlend = Blend.One, - }; - - private static readonly BlendState Add = new() - { - ColorSourceBlend = Blend.One, - ColorDestinationBlend = Blend.One, - AlphaSourceBlend = Blend.Zero, - AlphaDestinationBlend = Blend.One, - }; - - private readonly GraphicsDevice _device; - private readonly Dictionary _textures = []; - private readonly Texture2D _ramp; - private readonly Texture2D _glow; - private readonly DualTextureEffect _toon; - private readonly BasicEffect _flat; - private readonly short[][][] _indices; - - /// Uploads the model's textures, the ramp strip and the glow. - public CharacterView(GraphicsDevice device, CharacterData data) - { - _device = device; - foreach (var (name, texture) in data.Textures) - { - _textures[name] = BoardView.Upload(device, texture); - } - - _ramp = new Texture2D(device, data.Ramp.Width, 1); - _ramp.SetData(data.Ramp.Levels[0], RampRow * data.Ramp.Width * 4, data.Ramp.Width * 4); - _glow = new Texture2D(device, 64, 64); - _glow.SetData(GlowTexels()); - _toon = new DualTextureEffect(device) { DiffuseColor = new Vector3(Brightness), VertexColorEnabled = false }; - _flat = new BasicEffect(device) { TextureEnabled = true, LightingEnabled = false, VertexColorEnabled = false }; - - // The Reach profile takes 16-bit indices only; every mesh here is far below 65,536 vertices. - _indices = [.. data.Meshes.Select(m => m.Parts.Select(p => p.Indices.Select(i => (short)i).ToArray()).ToArray())]; - } - - /// Draws the posed models, opaque, with the light turned to stay screen-left at view yaw degrees. - public void DrawModels(IEnumerable models, Matrix view, Matrix projection, float viewYaw) - { - // UNKNOWN whether the original's light turns with the view; the footage never turns it. This keeps it screen-left. - var light = Vector3.TransformNormal(Light, Matrix.CreateRotationY(MathHelper.ToRadians(viewYaw))); - _toon.View = view; - _toon.Projection = projection; - _toon.World = Matrix.Identity; - _device.DepthStencilState = DepthStencilState.Default; - _device.BlendState = BlendState.Opaque; - _device.SamplerStates[0] = SamplerState.LinearWrap; - _device.SamplerStates[1] = SamplerState.LinearClamp; - foreach (var model in models) - { - for (var m = 0; m < model.Data.Meshes.Length; m++) - { - var mesh = model.Data.Meshes[m]; - if (mesh.Shadow) - { - continue; - } - - var (positions, normals) = (new Vector3[mesh.Positions.Length], new Vector3[mesh.Positions.Length]); - model.Skin(m, positions, normals); - for (var p = 0; p < mesh.Parts.Length; p++) - { - // The ramp coordinate is half-Lambert: 0 facing the light, 1 facing away. Self-lit parts use 0. - var part = mesh.Parts[p]; - var vertices = new RampVertex[positions.Length]; - for (var v = 0; v < vertices.Length; v++) - { - var ramp = part.Emissive ? 0 : (1 - Vector3.Dot(normals[v], light)) / 2; - vertices[v] = new RampVertex(positions[v], mesh.Uvs[v], new Vector2(ramp, 0.5f)); - } - - _toon.Texture = _textures[part.Texture]; - _toon.Texture2 = _ramp; - _device.RasterizerState = part.CullBack ? RasterizerState.CullCounterClockwise : RasterizerState.CullNone; - Submit(_toon, vertices, _indices[m][p]); - } - } - } - } - - /// Draws each model's shadow, then its lantern glow, over the opaque scene. - public void DrawShadowsAndGlows(IEnumerable models, Matrix view, Matrix projection) - { - var camera = Matrix.Invert(view); - _flat.View = view; - _flat.Projection = projection; - _flat.World = Matrix.Identity; - _device.DepthStencilState = DepthStencilState.DepthRead; - _device.SamplerStates[0] = SamplerState.LinearClamp; - foreach (var model in models) - { - for (var m = 0; m < model.Data.Meshes.Length; m++) - { - var mesh = model.Data.Meshes[m]; - if (!mesh.Shadow) - { - continue; - } - - var (positions, normals) = (new Vector3[mesh.Positions.Length], new Vector3[mesh.Positions.Length]); - model.Skin(m, positions, normals); - var vertices = positions.Select((p, v) => new VertexPositionTexture(p, mesh.Uvs[v])).ToArray(); - _flat.Texture = _textures[mesh.Parts[0].Texture]; - _flat.Alpha = ShadowStrength; - _device.BlendState = Subtract; - _device.RasterizerState = RasterizerState.CullCounterClockwise; - Submit(_flat, vertices, _indices[m][0]); - } - - // INFERRED: the glow is an effect the game attaches to the lantern's hook point; its code is protected. - var fire = model.World(model.Data.Fire).Translation; - var (right, up) = (camera.Right * GlowHalf, camera.Up * GlowHalf); - VertexPositionTexture[] quad = - [ - new(fire - right - up, new Vector2(0, 1)), new(fire + right - up, new Vector2(1, 1)), - new(fire - right + up, new Vector2(0, 0)), new(fire + right + up, new Vector2(1, 0)), - ]; - _flat.Texture = _glow; - _flat.Alpha = 1; - _device.BlendState = Add; - _device.RasterizerState = RasterizerState.CullNone; - Submit(_flat, quad, [0, 1, 2, 1, 3, 2]); - } - } - - /// - public void Dispose() - { - foreach (var texture in _textures.Values) - { - texture.Dispose(); - } - - _ramp.Dispose(); - _glow.Dispose(); - _toon.Dispose(); - _flat.Dispose(); - } - - private void Submit(Effect effect, T[] vertices, short[] indices) - where T : struct, IVertexType - { - foreach (var pass in effect.CurrentTechnique.Passes) - { - pass.Apply(); - _device.DrawUserIndexedPrimitives(PrimitiveType.TriangleList, vertices, 0, vertices.Length, indices, 0, indices.Length / 3); - } - } - - /// The glow: (120, 84, 40) at the center, fading with a smoothstep from 6 to 25 of its 30-unit radius. - private static Color[] GlowTexels() - { - var texels = new Color[64 * 64]; - for (var y = 0; y < 64; y++) - { - for (var x = 0; x < 64; x++) - { - var r = MathF.Sqrt(((x - 31.5f) * (x - 31.5f)) + ((y - 31.5f) * (y - 31.5f))) / 32 * 30; - var s = Math.Clamp((r - 6) / 19, 0, 1); - var fade = 1 - (s * s * (3 - (2 * s))); - texels[(y * 64) + x] = new Color(new Vector3(120, 84, 40) / 255 * fade); - } - } - - return texels; - } - - /// A vertex with the material's texture coordinate and the ramp coordinate, for . - [StructLayout(LayoutKind.Sequential, Pack = 1)] - private readonly struct RampVertex(Vector3 position, Vector2 uv, Vector2 ramp) : IVertexType - { - private static readonly VertexDeclaration Declaration = new( - new VertexElement(0, VertexElementFormat.Vector3, VertexElementUsage.Position, 0), - new VertexElement(12, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 0), - new VertexElement(20, VertexElementFormat.Vector2, VertexElementUsage.TextureCoordinate, 1)); - - private readonly Vector3 _position = position; - private readonly Vector2 _uv = uv; - private readonly Vector2 _ramp = ramp; - - VertexDeclaration IVertexType.VertexDeclaration => Declaration; - } -} diff --git a/src/PuzzleBoy/GameAssets.cs b/src/PuzzleBoy/GameAssets.cs index 38eb224..37a82a1 100644 --- a/src/PuzzleBoy/GameAssets.cs +++ b/src/PuzzleBoy/GameAssets.cs @@ -1,3 +1,4 @@ +using PuzzleBoy.Nocturne; using PuzzleBoy.Rules; namespace PuzzleBoy; diff --git a/src/PuzzleBoy/GameInstall.Character.cs b/src/PuzzleBoy/GameInstall.Character.cs deleted file mode 100644 index 0d92435..0000000 --- a/src/PuzzleBoy/GameInstall.Character.cs +++ /dev/null @@ -1,260 +0,0 @@ -using AssetsTools.NET; -using AssetsTools.NET.Extra; -using Microsoft.Xna.Framework; - -namespace PuzzleBoy; - -/// A node of the character's prefab, with its stored local transform. -/// The GameObject's name. -/// The parent's index in , or -1 for the root. -/// Local position, in Unity's axes. -/// Local rotation, a Unity quaternion (MonoGame reads the same numbers the same way). -/// Local scale. -internal sealed record CharacterNode(string Name, int Parent, Vector3 Position, Quaternion Rotation, Vector3 Scale); - -/// One submesh and the material it's drawn with. -/// Triangle indices into the mesh's vertices. -/// The material's main texture, by name in . -/// True when the material draws front faces only (Unity's _Cull 2). -/// True when the material lights itself (_EmiPower above 0), like Jack's eyes. -internal sealed record CharacterPart(int[] Indices, string Texture, bool CullBack, bool Emissive); - -/// A mesh on a node, rigid or skinned, in its renderer's local space. -/// The renderer's node. -/// Vertex positions. -/// Vertex normals. -/// Texture coordinates, Unity's (v up from the image's bottom row). -/// For a skinned mesh, the nodes its blend indices name; empty for a rigid one. -/// One per bone, as MonoGame matrices. -/// Per vertex, its bones (indices into ) and weights. -/// The submeshes. -/// True for the shadow quad, which subtracts from what's under it. -internal sealed record CharacterMesh( - int Node, Vector3[] Positions, Vector3[] Normals, Vector2[] Uvs, int[] Bones, Matrix[] BindPoses, - (int Bone, float Weight)[][] Influences, CharacterPart[] Parts, bool Shadow); - -/// An animated node property: which node, 1 position, 2 rotation or 3 scale, and its first curve. -/// The animated node's index. -/// 1 position, 2 rotation, 3 scale. -/// Its first curve in . -internal readonly record struct NodeBinding(int Node, int Attribute, int FirstCurve); - -/// Everything the port draws Jack-o'-Lantern with (re/m3-jack.md), read from the player's install. -/// The prefab's nodes, parents before children. -/// The node the game places on the board each tick (devil_0x3a, carrying the Animator). -/// The lantern's hook point, where the glow sits (h_fire). -/// The rigid and skinned meshes. -/// The materials' textures by name. -/// The idle clip's curves. -/// Which node property each group of curves drives. -/// The idle loop's length in ticks (the clip's length times 30). -/// The toon ramp texture, tone2 from the common bundle. -internal sealed record CharacterData( - CharacterNode[] Nodes, int Placed, int Fire, CharacterMesh[] Meshes, IReadOnlyDictionary Textures, - AnimationCurves Curves, NodeBinding[] Bindings, int LoopTicks, TextureData Ramp); - -/// Reading the character model. -public sealed partial class GameInstall -{ - // Unity's built-in Quad, which the shadow uses and no bundle holds: 1 x 1 in the xy plane, facing -z. - private static readonly Vector3[] QuadPositions = [new(-0.5f, -0.5f, 0), new(0.5f, -0.5f, 0), new(-0.5f, 0.5f, 0), new(0.5f, 0.5f, 0)]; - private static readonly Vector2[] QuadUvs = [new(0, 0), new(1, 0), new(0, 1), new(1, 1)]; - - /// - /// Jack-o'-Lantern from the dvl0x150 bundle: the devil_0x150 prefab's nodes, meshes, materials and - /// textures, and its Animator's default clip. Throws when the data isn't the shape - /// re/m3-jack.md records. - /// - /// The toon ramp, tone2 from the common bundle, read with that bundle's other textures. - internal CharacterData ReadCharacter(TextureData ramp) - { - var manager = new AssetsManager(); - try - { - var bundle = manager.LoadBundleFile(BundlePath(Directory, "dvl0x150"), true); - var file = manager.LoadAssetsFileFromBundle(bundle, 0, false); - AssetTypeValueField Get(AssetTypeValueField pointer) => manager.GetBaseField(file, file.file.GetAssetInfo(pointer["m_PathID"].AsLong)); - AssetTypeValueField? Component(AssetTypeValueField gameObject, AssetClassID type) => gameObject["m_Component.Array"].Children - .Select(c => c["component"]) - .Where(c => file.file.GetAssetInfo(c["m_PathID"].AsLong).TypeId == (int)type) - .Select(Get) - .FirstOrDefault(); - - // The bundle holds nine FBX imports with copies of every mesh, so start from the prefab: the root GameObject named devil_0x150. - var root = file.file.GetAssetsOfType(AssetClassID.GameObject) - .Select(info => manager.GetBaseField(file, info)) - .Where(go => go["m_Name"].AsString == "devil_0x150") - .Select(go => Component(go, AssetClassID.Transform)!) - .FirstOrDefault(t => t["m_Father.m_PathID"].AsLong == 0) - ?? throw new InvalidDataException("There's no devil_0x150 prefab in dvl0x150."); - - var nodes = new List(); - var gameObjects = new List(); - var byTransform = new Dictionary(); - void Walk(AssetTypeValueField transform, long id, int parent) - { - var go = Get(transform["m_GameObject"]); - byTransform[id] = nodes.Count; - nodes.Add(new CharacterNode(go["m_Name"].AsString, parent, Vector(transform["m_LocalPosition"]), Rotation(transform["m_LocalRotation"]), Vector(transform["m_LocalScale"]))); - gameObjects.Add(go); - var self = nodes.Count - 1; - foreach (var child in transform["m_Children.Array"].Children) - { - Walk(Get(child), child["m_PathID"].AsLong, self); - } - } - - var rootGo = file.file.GetAssetsOfType(AssetClassID.Transform).First(info => manager.GetBaseField(file, info)["m_GameObject.m_PathID"].AsLong == root["m_GameObject.m_PathID"].AsLong); - Walk(root, rootGo.PathId, -1); - - var textures = new Dictionary(); - var meshes = new List(); - for (var n = 0; n < nodes.Count; n++) - { - var skinned = Component(gameObjects[n], AssetClassID.SkinnedMeshRenderer); - var renderer = skinned ?? Component(gameObjects[n], AssetClassID.MeshRenderer); - if (renderer is null || !renderer["m_Enabled"].AsBool) - { - continue; - } - - var parts = new List<(string Texture, bool CullBack, bool Emissive)>(); - foreach (var pointer in renderer["m_Materials.Array"].Children) - { - var material = Get(pointer); - var main = material["m_SavedProperties.m_TexEnvs.Array"].Children.First(t => t["first"].AsString == "_MainTex")["second.m_Texture"]; - var texture = Get(main); - var name = texture["m_Name"].AsString; - if (!textures.ContainsKey(name)) - { - textures[name] = DecodeTexture(bundle, texture, "dvl0x150"); - } - - var floats = material["m_SavedProperties.m_Floats.Array"].Children.ToDictionary(f => f["first"].AsString, f => f["second"].AsFloat); - parts.Add((name, floats.GetValueOrDefault("_Cull") == 2, floats.GetValueOrDefault("_EmiPower") > 0)); - } - - var meshPointer = skinned is not null ? skinned["m_Mesh"] : Component(gameObjects[n], AssetClassID.MeshFilter)!["m_Mesh"]; - if (meshPointer["m_FileID"].AsInt != 0) - { - // A built-in mesh lives in Unity's own resources, not the bundle; the only one here is the shadow quad. - var (cull, emissive) = (parts[0].CullBack, parts[0].Emissive); - meshes.Add(new CharacterMesh(n, QuadPositions, [.. QuadPositions.Select(_ => -Vector3.UnitZ)], QuadUvs, [], [], [], - [new CharacterPart([0, 3, 1, 3, 0, 2], parts[0].Texture, cull, emissive)], Shadow: true)); - continue; - } - - var bones = skinned?["m_Bones.Array"].Children.Select(b => byTransform[b["m_PathID"].AsLong]).ToArray() ?? []; - meshes.Add(ReadMesh(Get(meshPointer), n, bones, parts)); - } - - var placed = gameObjects.FindIndex(go => Component(go, AssetClassID.Animator) is not null); - var (curves, bindings, loopTicks) = ReadIdle(Get(Component(gameObjects[placed], AssetClassID.Animator)!["m_Controller"]), Get, nodes, placed); - var fire = nodes.FindIndex(node => node.Name == "h_fire"); - return new CharacterData([.. nodes], placed, fire, [.. meshes], textures, curves, bindings, loopTicks, ramp); - } - finally - { - manager.UnloadAll(); - } - } - - private static CharacterMesh ReadMesh(AssetTypeValueField mesh, int node, int[] bones, List<(string Texture, bool CullBack, bool Emissive)> parts) - { - if (mesh["m_StreamData.size"].AsUInt > 0 || mesh["m_CompressedMesh.m_Vertices.m_NumItems"].AsUInt > 0) - { - throw new InvalidDataException($"Mesh {mesh["m_Name"].AsString} is streamed or compressed, which the port doesn't read."); - } - - var vertexData = mesh["m_VertexData"]; - var count = (int)vertexData["m_VertexCount"].AsUInt; - var channels = vertexData["m_Channels.Array"].Children - .Select(c => new VertexChannel(c["stream"].AsByte, c["offset"].AsByte, c["format"].AsByte, c["dimension"].AsByte & 0xF)) - .ToArray(); - var data = vertexData["m_DataSize"].AsByteArray; - var positions = UnityFormats.Read(data, channels, UnityFormats.Position, count)!; - var normals = UnityFormats.Read(data, channels, UnityFormats.Normal, count)!; - var uvs = UnityFormats.Read(data, channels, UnityFormats.Uv0, count)!; - var weights = UnityFormats.Read(data, channels, UnityFormats.BlendWeight, count); - var indices = UnityFormats.Read(data, channels, UnityFormats.BlendIndices, count); - - var influences = new (int Bone, float Weight)[count][]; - if (indices is not null) - { - var per = indices.Length / count; - for (var v = 0; v < count; v++) - { - // A mesh with blend indices but no weights gives each vertex all of its one bone. - influences[v] = [.. Enumerable.Range(0, per).Select(i => ((int)indices[(v * per) + i], weights?[(v * per) + i] ?? 1f)).Where(x => x.Item2 > 0)]; - } - } - - var buffer = mesh["m_IndexBuffer.Array"].AsByteArray; - var wide = mesh["m_IndexFormat"].AsInt == 1; - var submeshes = mesh["m_SubMeshes.Array"].Children.ToArray(); - var built = submeshes.Select((s, i) => new CharacterPart( - UnityFormats.Indices(buffer, wide, (int)s["firstByte"].AsUInt, (int)s["indexCount"].AsUInt), parts[i].Texture, parts[i].CullBack, parts[i].Emissive)).ToArray(); - var bindPoses = mesh["m_BindPose.Array"].Children - .Select(m => UnityFormats.Matrix([.. Enumerable.Range(0, 16).Select(i => m[$"e{i / 4}{i % 4}"].AsFloat)])) - .ToArray(); - - return new CharacterMesh(node, - [.. Enumerable.Range(0, count).Select(v => new Vector3(positions[3 * v], positions[(3 * v) + 1], positions[(3 * v) + 2]))], - [.. Enumerable.Range(0, count).Select(v => new Vector3(normals[3 * v], normals[(3 * v) + 1], normals[(3 * v) + 2]))], - [.. Enumerable.Range(0, count).Select(v => new Vector2(uvs[2 * v], uvs[(2 * v) + 1]))], - bones, bindPoses, indices is null ? [] : influences, built, Shadow: false); - } - - /// The Animator's default state's clip: the controller's first state machine, its default state, its first motion. - private static (AnimationCurves Curves, NodeBinding[] Bindings, int LoopTicks) ReadIdle( - AssetTypeValueField controller, Func get, List nodes, int animated) - { - var machine = controller["m_Controller.m_StateMachineArray.Array"].Children[0]["data"]; - var state = machine["m_StateConstantArray.Array"].Children[(int)machine["m_DefaultState"].AsUInt]["data"]; - var leaf = state["m_BlendTreeConstantArray.Array"].Children[0]["data.m_NodeArray.Array"].Children[0]["data"]; - var clip = get(controller["m_AnimationClips.Array"].Children[(int)leaf["m_ClipID"].AsUInt]); - - var muscle = clip["m_MuscleClip"]; - var data = muscle["m_Clip.data"]; - var streamed = data["m_StreamedClip.data.Array"].Children.Select(w => w.AsUInt).ToArray(); - var curves = AnimationCurves.Parse(streamed, (int)data["m_StreamedClip.curveCount"].AsUInt, (int)data["m_DenseClip.m_CurveCount"].AsUInt, - [.. data["m_ConstantClip.data.Array"].Children.Select(f => f.AsFloat)]); - - // Bindings name nodes by the CRC-32 of their path below the animated node, which itself is "". - var byHash = new Dictionary(); - for (var n = 0; n < nodes.Count; n++) - { - if (PathBelow(nodes, n, animated) is { } path) - { - byHash[UnityFormats.PathHash(path)] = n; - } - } - - var bindings = UnityFormats.Bind( - clip["m_ClipBindingConstant.genericBindings.Array"].Children.Select(b => (b["path"].AsUInt, (int)b["attribute"].AsUInt, b["typeID"].AsInt)), - curves.Count); - var loopTicks = (int)MathF.Round((muscle["m_StopTime"].AsFloat - muscle["m_StartTime"].AsFloat) * 30); - return (curves, [.. bindings.Select(b => new NodeBinding(byHash[b.Path], b.Attribute, b.FirstCurve))], loopTicks); - } - - /// Node 's path below , or null when it isn't below it. - private static string? PathBelow(List nodes, int n, int ancestor) - { - var names = new List(); - for (var at = n; at != ancestor; at = nodes[at].Parent) - { - if (at < 0) - { - return null; - } - - names.Insert(0, nodes[at].Name); - } - - return string.Join('/', names); - } - - private static Vector3 Vector(AssetTypeValueField f) => new(f["x"].AsFloat, f["y"].AsFloat, f["z"].AsFloat); - - private static Quaternion Rotation(AssetTypeValueField f) => new(f["x"].AsFloat, f["y"].AsFloat, f["z"].AsFloat, f["w"].AsFloat); -} diff --git a/src/PuzzleBoy/GameInstall.Text.cs b/src/PuzzleBoy/GameInstall.Text.cs deleted file mode 100644 index a97c573..0000000 --- a/src/PuzzleBoy/GameInstall.Text.cs +++ /dev/null @@ -1,107 +0,0 @@ -using AssetsTools.NET; -using AssetsTools.NET.Extra; -using Microsoft.Xna.Framework; - -namespace PuzzleBoy; - -/// The game's font, its text materials and the English strings Puzzle Boy shows. -/// The TextMeshPro font, FOT-MatisseProN-B SDF. -/// Its materials by name, such as MsgFont0 or TMC22. -/// Strings by id, such as PBMESSAGE_L0002, PBHELP_L0046 or GUIDE_L0012. -internal sealed record TextData(FontData Font, IReadOnlyDictionary Materials, IReadOnlyDictionary Strings); - -/// Reading the font and the text. -public sealed partial class GameInstall -{ - private const string FontName = "FOT-MatisseProN-B SDF"; - - /// - /// The font and materials from font-en and the strings of PBMESSAGE_EN, PUZZLEBOY_EN - /// (pazzleboy_en) and GUIDE_EN (common_en). Throws when - /// something is missing or the atlas isn't one byte per texel. - /// - // shortcut: English only; other languages need their bundles and wrapping, since the boxes were sized for Japanese. - internal TextData ReadText() - { - var strings = new Dictionary(); - foreach (var (bundle, table) in new[] { ("pazzleboy_en", "PBMESSAGE_EN"), ("pazzleboy_en", "PUZZLEBOY_EN"), ("common_en", "GUIDE_EN") }) - { - ReadAssets(bundle, (manager, file, _) => - { - var t = Behaviours(manager, file).FirstOrDefault(b => b["m_Name"].AsString == table) - ?? throw new InvalidDataException($"There's no text table {table} in {bundle}."); - foreach (var entry in t["_eventEMsgData.Array"].Children) - { - strings[entry["msgID"].AsString] = entry["msgStr"].AsString; - } - }); - } - - FontData font = null!; - var materials = new Dictionary(); - ReadAssets("font-en", (manager, file, bundle) => - { - var f = Behaviours(manager, file).FirstOrDefault(b => b["m_Name"].AsString == FontName) - ?? throw new InvalidDataException($"There's no font {FontName} in font-en."); - var glyphs = f["m_GlyphTable.Array"].Children.ToDictionary(g => g["m_Index"].AsUInt); - var characters = new Dictionary(); - foreach (var c in f["m_CharacterTable.Array"].Children) - { - var g = glyphs[c["m_GlyphIndex"].AsUInt]; - var (m, r) = (g["m_Metrics"], g["m_GlyphRect"]); - var unicode = c["m_Unicode"].AsUInt; - if (unicode <= char.MaxValue) - { - characters[(char)unicode] = new FontGlyph(m["m_Width"].AsFloat, m["m_Height"].AsFloat, m["m_HorizontalBearingX"].AsFloat, - m["m_HorizontalBearingY"].AsFloat, m["m_HorizontalAdvance"].AsFloat, new Rectangle(r["m_X"].AsInt, r["m_Y"].AsInt, r["m_Width"].AsInt, r["m_Height"].AsInt)); - } - } - - var atlas = manager.GetBaseField(file, file.file.GetAssetInfo(f["m_AtlasTextures.Array"].Children[0]["m_PathID"].AsLong)); - if (atlas["m_TextureFormat"].AsInt != 1) - { - throw new InvalidDataException($"The font atlas has format {atlas["m_TextureFormat"].AsInt}; the port reads Alpha8 only."); - } - - var stream = atlas["m_StreamData"]; - var data = stream["size"].AsUInt > 0 - ? ReadEntry(bundle, stream["path"].AsString, stream["offset"].AsULong, stream["size"].AsUInt) - : atlas["image data"].AsByteArray; - var face = f["m_FaceInfo"]; - font = new FontData(face["m_PointSize"].AsFloat, face["m_AscentLine"].AsFloat, face["m_DescentLine"].AsFloat, - characters, atlas["m_Width"].AsInt, atlas["m_Height"].AsInt, data); - - foreach (var info in file.file.GetAssetsOfType(AssetClassID.Material)) - { - var material = manager.GetBaseField(file, info); - var properties = material["m_SavedProperties"]; - var colours = properties["m_Colors.Array"].Children.ToDictionary(e => e["first"].AsString, e => new Vector4(e["second.r"].AsFloat, e["second.g"].AsFloat, e["second.b"].AsFloat, e["second.a"].AsFloat)); - var floats = properties["m_Floats.Array"].Children.ToDictionary(e => e["first"].AsString, e => e["second"].AsFloat); - if (colours.ContainsKey("_FaceColor") && floats.ContainsKey("_GradientScale")) - { - materials.TryAdd(material["m_Name"].AsString, new TextMaterial(colours["_FaceColor"], colours["_OutlineColor"], colours["_UnderlayColor"], - floats["_FaceDilate"], floats["_OutlineWidth"], floats["_UnderlayDilate"], floats["_ScaleRatioA"], floats["_ScaleRatioC"], floats["_GradientScale"])); - } - } - }); - - return new TextData(font, materials, strings); - } - - private void ReadAssets(string bundle, Action read) - { - var manager = new AssetsManager(); - try - { - var bundleFile = manager.LoadBundleFile(BundlePath(Directory, bundle), true); - read(manager, manager.LoadAssetsFileFromBundle(bundleFile, 0, false), bundleFile); - } - finally - { - manager.UnloadAll(); - } - } - - private static IEnumerable Behaviours(AssetsManager manager, AssetsFileInstance file) => - file.file.GetAssetsOfType(AssetClassID.MonoBehaviour).Select(info => manager.GetBaseField(file, info)); -} diff --git a/src/PuzzleBoy/GameInstall.cs b/src/PuzzleBoy/GameInstall.cs deleted file mode 100644 index 50fca44..0000000 --- a/src/PuzzleBoy/GameInstall.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Buffers.Binary; -using System.Text.RegularExpressions; -using AssetsTools.NET; -using AssetsTools.NET.Extra; -using Fmod5Sharp; -using Microsoft.Win32; -using NVorbis; - -namespace PuzzleBoy; - -/// A texture from the game, every mip level as RGBA bytes in Unity's row order: the bottom row first. -/// The top level's width in pixels. -/// The top level's height in pixels. -/// The mip levels, largest first, each half the size of the one before. -public sealed record TextureData(int Width, int Height, IReadOnlyList Levels); - -/// A sound from the game as 16-bit little-endian PCM, channels interleaved. -/// The samples. -/// Samples per second per channel. -/// 1 or 2. -public sealed record SoundData(byte[] Pcm, int SampleRate, int Channels) -{ - /// Converts float samples (-1 to 1) to 16-bit PCM, clamping anything louder. - public static byte[] Pcm16(ReadOnlySpan samples) - { - var pcm = new byte[samples.Length * 2]; - for (var i = 0; i < samples.Length; i++) - { - var value = (short)Math.Clamp(MathF.Round(samples[i] * short.MaxValue), short.MinValue, short.MaxValue); - BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(2 * i), value); - } - - return pcm; - } -} - -/// The player's copy of Nocturne HD Remaster. Every asset the port uses is read from it. -public sealed partial class GameInstall -{ - private GameInstall(string directory) => Directory = directory; - - /// The game's folder, the one that holds smt3hd.exe and smt3hd_Data. - public string Directory { get; } - - /// The game at , or null when its Puzzle Boy bundle isn't there. - public static GameInstall? At(string directory) => - File.Exists(BundlePath(directory, "pazzleboy")) ? new GameInstall(directory) : null; - - /// Looks for the game in every Steam library on this machine. - public static GameInstall? Find() => - SteamRoots() - .SelectMany(root => LibraryFolders(root).Prepend(root)) - .Select(library => At(Path.Combine(library, "steamapps", "common", "smt3hd"))) - .FirstOrDefault(install => install is not null); - - /// The library paths listed in Steam's libraryfolders.vdf. - public static IEnumerable LibraryPaths(string libraryFoldersVdf) => - PathEntry().Matches(libraryFoldersVdf).Select(match => match.Groups[1].Value.Replace(@"\\", @"\")); - - /// Every TextAsset in one of the game's bundles, by name. - public Dictionary ReadTextAssets(string bundle) - { - var manager = new AssetsManager(); - try - { - var bundleFile = manager.LoadBundleFile(BundlePath(Directory, bundle), true); - var assets = manager.LoadAssetsFileFromBundle(bundleFile, 0, false); - return assets.file.GetAssetsOfType(AssetClassID.TextAsset) - .Select(info => manager.GetBaseField(assets, info)) - .ToDictionary(field => field["m_Name"].AsString, field => field["m_Script"].AsByteArray); - } - finally - { - manager.UnloadAll(); - } - } - - /// - /// Texture2D from one of the game's bundles, every mip level decoded to RGBA. - /// Throws when it's missing or in a format the port doesn't read (DXT1, RGB24 and RGBA32 only). - /// - public TextureData ReadTexture(string bundle, string name) => ReadTextures(bundle, name)[name]; - - /// - /// Several textures from one bundle, by name, opening it once: a large bundle like common takes a while to - /// unpack. Throws as does. - /// - public Dictionary ReadTextures(string bundle, params string[] names) - { - var manager = new AssetsManager(); - try - { - var bundleFile = manager.LoadBundleFile(BundlePath(Directory, bundle), true); - var assets = manager.LoadAssetsFileFromBundle(bundleFile, 0, false); - var found = assets.file.GetAssetsOfType(AssetClassID.Texture2D) - .Select(info => manager.GetBaseField(assets, info)) - .Where(field => names.Contains(field["m_Name"].AsString)) - .GroupBy(field => field["m_Name"].AsString) - .ToDictionary(group => group.Key, group => DecodeTexture(bundleFile, group.First(), bundle)); - var missing = names.FirstOrDefault(name => !found.ContainsKey(name)); - return missing is null ? found : throw new InvalidDataException($"There's no texture {missing} in {bundle}."); - } - finally - { - manager.UnloadAll(); - } - } - - /// Decodes a Texture2D's every mip level; see . - private static TextureData DecodeTexture(BundleFileInstance bundleFile, AssetTypeValueField texture, string bundle) - { - // Large textures keep their pixels in the bundle's .resS entry; small ones inline. - var stream = texture["m_StreamData"]; - var data = stream["size"].AsUInt > 0 - ? ReadEntry(bundleFile, stream["path"].AsString, stream["offset"].AsULong, stream["size"].AsUInt) - : texture["image data"].AsByteArray; - - var (width, height, format) = (texture["m_Width"].AsInt, texture["m_Height"].AsInt, texture["m_TextureFormat"].AsInt); - var levels = new List(); - var at = 0; - for (var level = 0; level < texture["m_MipCount"].AsInt; level++) - { - var (w, h) = (Math.Max(1, width >> level), Math.Max(1, height >> level)); - var (pixels, size) = format switch - { - 10 => (TextureDecoder.Dxt1(data.AsSpan(at), w, h), TextureDecoder.Dxt1Size(w, h)), - 3 => (TextureDecoder.Rgb24(data.AsSpan(at), w, h), w * h * 3), - 4 => (data.AsSpan(at, w * h * 4).ToArray(), w * h * 4), - _ => throw new InvalidDataException($"Texture {texture["m_Name"].AsString} in {bundle} has format {format}, which the port doesn't read."), - }; - levels.Add(pixels); - at += size; - } - - return new TextureData(width, height, levels); - } - - /// - /// Every AudioClip in one of the game's bundles, by name, decoded to 16-bit PCM. The clips are Vorbis inside - /// FMOD sound banks (FSB5). Throws for a clip in another codec. - /// - public Dictionary ReadSounds(string bundle) - { - var manager = new AssetsManager(); - try - { - var bundleFile = manager.LoadBundleFile(BundlePath(Directory, bundle), true); - var assets = manager.LoadAssetsFileFromBundle(bundleFile, 0, false); - var sounds = new Dictionary(); - foreach (var clip in assets.file.GetAssetsOfType(AssetClassID.AudioClip).Select(info => manager.GetBaseField(assets, info))) - { - var name = clip["m_Name"].AsString; - var resource = clip["m_Resource"]; - var bank = FsbLoader.LoadFsbFromByteArray(ReadEntry(bundleFile, resource["m_Source"].AsString, resource["m_Offset"].AsULong, (uint)resource["m_Size"].AsULong)); - if (!bank.Samples[0].RebuildAsStandardFileFormat(out var ogg, out var extension) || extension != "ogg") - { - throw new InvalidDataException($"Sound {name} in {bundle} isn't Vorbis, which the port doesn't read."); - } - - using var reader = new VorbisReader(new MemoryStream(ogg!), true); - var samples = new float[reader.TotalSamples * reader.Channels]; - var read = 0; - while (read < samples.Length && reader.ReadSamples(samples, read, samples.Length - read) is > 0 and var n) - { - read += n; - } - - sounds[name] = new SoundData(SoundData.Pcm16(samples.AsSpan(0, read)), reader.SampleRate, reader.Channels); - } - - return sounds; - } - finally - { - manager.UnloadAll(); - } - } - - /// Reads bytes at of a bundle entry named by a Unity resource path. - private static byte[] ReadEntry(BundleFileInstance bundle, string path, ulong offset, uint size) - { - var file = bundle.file; - file.GetFileRange(file.GetFileIndex(Path.GetFileName(path)), out var start, out _); - file.DataReader.Position = start + (long)offset; - return file.DataReader.ReadBytes((int)size); - } - - private static string BundlePath(string directory, string bundle) => - Path.Combine(directory, "smt3hd_Data", "StreamingAssets", "PC", bundle); - - private static IEnumerable LibraryFolders(string steamRoot) - { - var vdf = Path.Combine(steamRoot, "steamapps", "libraryfolders.vdf"); - return File.Exists(vdf) ? LibraryPaths(File.ReadAllText(vdf)) : []; - } - - private static IEnumerable SteamRoots() - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (OperatingSystem.IsWindows()) - { - if (Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam", "SteamPath", null) is string steamPath) - { - yield return steamPath; - } - - yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam"); - } - else if (OperatingSystem.IsMacOS()) - { - yield return Path.Combine(home, "Library", "Application Support", "Steam"); - } - else - { - // Native, the Debian package's symlink, and Flatpak. The game itself runs under Proton there. - yield return Path.Combine(home, ".local", "share", "Steam"); - yield return Path.Combine(home, ".steam", "steam"); - yield return Path.Combine(home, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"); - } - } - - [GeneratedRegex("\"path\"\\s+\"([^\"]+)\"")] - private static partial Regex PathEntry(); -} diff --git a/src/PuzzleBoy/Hud.cs b/src/PuzzleBoy/Hud.cs index bd5780b..cf081a2 100644 --- a/src/PuzzleBoy/Hud.cs +++ b/src/PuzzleBoy/Hud.cs @@ -1,5 +1,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; +using PuzzleBoy.Nocturne; +using PuzzleBoy.Rendering; namespace PuzzleBoy; diff --git a/src/PuzzleBoy/MoveAnimation.cs b/src/PuzzleBoy/MoveAnimation.cs deleted file mode 100644 index 92d63f0..0000000 --- a/src/PuzzleBoy/MoveAnimation.cs +++ /dev/null @@ -1,68 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy; - -/// -/// Plays one step with the original's timing (ROADMAP.md, "M3 findings": moves). The board has already changed; -/// this only says how far along the picture is. Age 0 is the tick that took the input. -/// -/// The step to play. -/// Where the moving character started. -internal sealed class MoveAnimation(Move move, (int X, int Y) from) -{ - /// The step being played. - public Move Move { get; } = move; - - /// Where the moving character started. - public (int X, int Y) From { get; } = from; - - /// Ticks since the input tick. - public int Age { get; private set; } - - /// How much of the 4-tick slide shows, 0.25 to 1. The original draws the first quarter on the input tick. - public float Slide => Math.Min(Age + 1, 4) / 4f; - - /// How far a fallen block has sunk, 0 to 1 cell. It sinks on the 4 ticks after the push. - public float Sink => Move.Fell ? Math.Clamp((Age - 3) / 4f, 0, 1) : 0; - - /// True while a fallen block is still visible, so the holes under it should still look open. - public bool Falling => Move.Fell && Age < 8; - - /// True on the tick a fall ends, when the original fills the holes and shakes the camera. - public bool FallEnds => Move.Fell && Age == 8; - - /// True on the tick a fall starts, right after the push: the original plays SE03 then. - public bool FallStarts => Move.Fell && Age == 4; - - /// The cell the moving character ends on. - public (int X, int Y) Destination - { - get - { - var (dx, dy) = Move.Direction.Offset(); - return (From.X + (dx * Move.Cells), From.Y + (dy * Move.Cells)); - } - } - - /// True on the last tick of the slide, when the original checks for the goal and plays SE09 on arrival. - public bool ArrivesAt((int X, int Y) goal) => Age == 3 && Destination == goal; - - /// True once the original would read the next input: 4 ticks after a walk, 5 after a push or turn, 9 after a fall. - public bool Done => Age >= (Move.Fell ? 9 : Move.Result == StepResult.Walked ? 4 : 5); - - /// Advances one tick. - public void Tick() => Age++; - - /// Where character is drawn, in cells: sliding from its start while its move plays. - public static (float X, float Y) Where(Board board, MoveAnimation? animation, int index) - { - var c = board.Characters[index]; - if (animation is null || animation.Move.Mover != index) - { - return (c.X, c.Y); - } - - var slide = animation.Slide; - return (animation.From.X + ((c.X - animation.From.X) * slide), animation.From.Y + ((c.Y - animation.From.Y) * slide)); - } -} diff --git a/src/PuzzleBoy/Program.cs b/src/PuzzleBoy/Program.cs index 7b38a67..4099772 100644 --- a/src/PuzzleBoy/Program.cs +++ b/src/PuzzleBoy/Program.cs @@ -1,4 +1,5 @@ using PuzzleBoy; +using PuzzleBoy.Nocturne; // shortcut: a missing game is reported on stderr before any window opens; an in-game message needs the fonts M3 brings. // The bundles copied beside the executable come first, so a double-click works with the game uninstalled. diff --git a/src/PuzzleBoy/PuzzleBoy.csproj b/src/PuzzleBoy/PuzzleBoy.csproj index e1ca597..b0deac5 100644 --- a/src/PuzzleBoy/PuzzleBoy.csproj +++ b/src/PuzzleBoy/PuzzleBoy.csproj @@ -12,15 +12,14 @@
- + - - - + + diff --git a/src/PuzzleBoy/PuzzleBoyGame.cs b/src/PuzzleBoy/PuzzleBoyGame.cs index 648faf2..4c3bfbb 100644 --- a/src/PuzzleBoy/PuzzleBoyGame.cs +++ b/src/PuzzleBoy/PuzzleBoyGame.cs @@ -1,6 +1,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; +using PuzzleBoy.Rendering; using PuzzleBoy.Rules; namespace PuzzleBoy; diff --git a/src/PuzzleBoy/Sounds.cs b/src/PuzzleBoy/Sounds.cs deleted file mode 100644 index 7effaf3..0000000 --- a/src/PuzzleBoy/Sounds.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.Xna.Framework.Audio; - -namespace PuzzleBoy; - -/// -/// The minigame's sound effects and music from the player's install (ROADMAP.md, "M3 findings": sound). -/// Every effect plays at full volume, as in the original. -/// -internal sealed class Sounds : IDisposable -{ - private const string Music = "JYAKYO02_BGM00_L_"; - - private readonly Dictionary _effects = []; - private SoundEffectInstance? _music; - - /// Creates the effects; on a machine with no audio device the game stays silent instead of failing. - public Sounds(IReadOnlyDictionary sounds) - { - try - { - foreach (var (name, sound) in sounds) - { - _effects[name] = new SoundEffect(sound.Pcm, sound.SampleRate, sound.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo); - } - } - catch (NoAudioHardwareException) - { - // shortcut: no message when there's no audio device; one comes with the in-game error screen, if ever. - _effects.Clear(); - } - } - - /// Plays effect JYAKYO02_SE: 0 step, 1 push, 2 turn, 3 fall, 8 view, 9 goal. - public void Play(int number) - { - if (_effects.TryGetValue($"JYAKYO02_SE{number:D2}", out var effect)) - { - effect.Play(); - } - } - - /// Starts the music. The _L_ in its name marks a looping track, so it loops whole. - public void StartMusic() - { - if (_music is null && _effects.TryGetValue(Music, out var music)) - { - _music = music.CreateInstance(); - _music.IsLooped = true; - _music.Play(); - } - } - - /// - public void Dispose() - { - _music?.Dispose(); - foreach (var effect in _effects.Values) - { - effect.Dispose(); - } - } -} diff --git a/src/PuzzleBoy/TextRenderer.cs b/src/PuzzleBoy/TextRenderer.cs index 35d3910..e36f906 100644 --- a/src/PuzzleBoy/TextRenderer.cs +++ b/src/PuzzleBoy/TextRenderer.cs @@ -1,59 +1,8 @@ using Microsoft.Xna.Framework; +using PuzzleBoy.Nocturne; namespace PuzzleBoy; -/// A glyph of the TextMeshPro font, in font units at the font's point size (1 unit = 1 atlas texel there). -/// The metric box's width. -/// The metric box's height. -/// From the pen to the box's left edge. -/// From the baseline up to the box's top. -/// How far the pen moves. -/// The glyph's rect in the atlas, in texels from the bottom-left as Unity stores it. -internal readonly record struct FontGlyph(float Width, float Height, float BearingX, float BearingY, float Advance, Rectangle Rect); - -/// The game's TextMeshPro SDF font: glyphs and the distance-field atlas, one byte per texel, rows bottom-up. -/// The size the glyph metrics are given at. -/// The ascent line, in font units. -/// The descent line, in font units (negative). -/// Glyphs by character. -/// The atlas's width in texels. -/// The atlas's height in texels. -/// The atlas: 0.5 + distance / 22 per texel as a byte, positive inside. -internal sealed record FontData(float PointSize, float Ascent, float Descent, IReadOnlyDictionary Glyphs, int AtlasWidth, int AtlasHeight, byte[] Atlas); - -/// A TextMeshPro distance-field material: colours and the floats that place its edges. -/// The face colour, straight alpha. -/// The outline colour. -/// The underlay colour; its alpha is the underlay's strength. -/// TMP's _FaceDilate. -/// TMP's _OutlineWidth. -/// TMP's _UnderlayDilate. -/// TMP's _ScaleRatioA. -/// TMP's _ScaleRatioC. -/// TMP's _GradientScale: the atlas's distance range in texels. -internal sealed record TextMaterial( - Vector4 Face, Vector4 Outline, Vector4 Underlay, float FaceDilate, float OutlineWidth, float UnderlayDilate, - float ScaleRatioA, float ScaleRatioC, float GradientScale) -{ - // shortcut: FIT. The footage's text is slightly thinner than these floats give; raising the face edge by 0.035 - // matches it best at 720p (re/m3-ui.md, 3.2). Revisit with a 1080p capture. - private const float FaceFit = 0.035f; - - private float Weight => FaceDilate * ScaleRatioA / 2; - - /// The atlas value where the face's coverage is half, as the material sets it (no fit). - public float FaceEdge => 0.5f - Weight - (OutlineWidth * ScaleRatioA / 2); - - /// Where the colour turns from face to outline. - public float ColourEdge => 0.5f - Weight + (OutlineWidth * ScaleRatioA / 2); - - /// Where the underlay's coverage is half. - public float UnderlayEdge => 0.5f - Weight - (UnderlayDilate * ScaleRatioC / 2); - - /// The face edge the port draws with, thinned to match the footage. - public float DrawnFaceEdge => FaceEdge + FaceFit; -} - /// A line of text baked to premultiplied RGBA at whole-pixel position (, ). /// The image's left edge in window pixels. /// The image's top edge in window pixels. diff --git a/src/PuzzleBoy/TextureDecoder.cs b/src/PuzzleBoy/TextureDecoder.cs deleted file mode 100644 index 065d2a9..0000000 --- a/src/PuzzleBoy/TextureDecoder.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Buffers.Binary; - -namespace PuzzleBoy; - -/// -/// Decodes Unity texture data to RGBA bytes, 4 per pixel, keeping Unity's row order (bottom row first). -/// Decoding on the CPU keeps every platform the same, whatever compressed formats its GPU takes. -/// -internal static class TextureDecoder -{ - /// The bytes one mip level of a DXT1 texture takes: 8 per 4x4 block, partial blocks rounded up. - public static int Dxt1Size(int width, int height) => Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8; - - /// Decodes one DXT1 (BC1) mip level. A block whose first color isn't greater uses index 3 for transparent black. - public static byte[] Dxt1(ReadOnlySpan data, int width, int height) - { - var rgba = new byte[width * height * 4]; - var at = 0; - Span palette = stackalloc uint[4]; - for (var by = 0; by < height; by += 4) - { - for (var bx = 0; bx < width; bx += 4, at += 8) - { - var c0 = BinaryPrimitives.ReadUInt16LittleEndian(data[at..]); - var c1 = BinaryPrimitives.ReadUInt16LittleEndian(data[(at + 2)..]); - var bits = BinaryPrimitives.ReadUInt32LittleEndian(data[(at + 4)..]); - var (a, b) = (Rgb565(c0), Rgb565(c1)); - palette[0] = Pack(a, 255); - palette[1] = Pack(b, 255); - palette[2] = c0 > c1 ? Pack(Mix(a, b, 2, 1, 3), 255) : Pack(Mix(a, b, 1, 1, 2), 255); - palette[3] = c0 > c1 ? Pack(Mix(a, b, 1, 2, 3), 255) : 0; - for (var i = 0; i < 16; i++) - { - var (x, y) = (bx + (i % 4), by + (i / 4)); - if (x < width && y < height) - { - BinaryPrimitives.WriteUInt32LittleEndian(rgba.AsSpan(4 * ((y * width) + x)), palette[(int)((bits >> (2 * i)) & 3)]); - } - } - } - } - - return rgba; - } - - /// Expands RGB24 to RGBA with full alpha. - public static byte[] Rgb24(ReadOnlySpan data, int width, int height) - { - var rgba = new byte[width * height * 4]; - for (var i = 0; i < width * height; i++) - { - data.Slice(3 * i, 3).CopyTo(rgba.AsSpan(4 * i)); - rgba[(4 * i) + 3] = 255; - } - - return rgba; - } - - private static (int R, int G, int B) Rgb565(ushort c) => ((c >> 11) * 255 / 31, ((c >> 5) & 63) * 255 / 63, (c & 31) * 255 / 31); - - private static (int R, int G, int B) Mix((int R, int G, int B) a, (int R, int G, int B) b, int wa, int wb, int d) => - (((a.R * wa) + (b.R * wb)) / d, ((a.G * wa) + (b.G * wb)) / d, ((a.B * wa) + (b.B * wb)) / d); - - /// A pixel as little-endian RGBA bytes. - private static uint Pack((int R, int G, int B) c, int alpha) => (uint)(c.R | (c.G << 8) | (c.B << 16) | (alpha << 24)); -} diff --git a/src/PuzzleBoy/UnityFormats.cs b/src/PuzzleBoy/UnityFormats.cs deleted file mode 100644 index 204c486..0000000 --- a/src/PuzzleBoy/UnityFormats.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System.Buffers.Binary; -using Microsoft.Xna.Framework; - -namespace PuzzleBoy; - -/// A vertex channel of a Unity 2019 mesh: its stream, its byte offset in a vertex, its format and its component count. -/// The vertex stream that holds it. -/// Its byte offset inside one vertex of that stream. -/// Unity's vertex format: 0 is float32 and 10 is uint32, the only two the port reads. -/// Components per vertex; 0 means the mesh doesn't have this channel. -internal readonly record struct VertexChannel(int Stream, int Offset, int Format, int Dimension); - -/// A Transform binding of an animation clip: the node's path hash, what it animates, and its first curve. -/// The CRC-32 of the node's path below the animated object (). -/// 1 position, 2 rotation, 3 scale. -/// The first of its 3 (position, scale) or 4 (rotation) curves. -internal readonly record struct ClipBinding(uint Path, int Attribute, int FirstCurve); - -/// -/// Decoders for the Unity 2019 mesh and animation data Jack-o'-Lantern is made of (re/m3-jack.md has the layouts). -/// They work on plain arrays so tests can feed them made-up numbers; only reads fields. -/// -internal static class UnityFormats -{ - /// Unity's fixed channel slots in 2019. - public const int Position = 0, Normal = 1, Uv0 = 4, BlendWeight = 12, BlendIndices = 13; - - private static readonly uint[] Crc = Enumerable.Range(0, 256).Select(n => - { - var c = (uint)n; - for (var k = 0; k < 8; k++) - { - c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; - } - - return c; - }).ToArray(); - - /// - /// Each stream's start and stride. A stream's channels are interleaved per vertex, and each stream starts where the - /// one before ends, rounded up to 16 bytes. - /// - public static (int Start, int Stride)[] Streams(IReadOnlyList channels, int vertexCount) - { - var count = channels.Where(c => c.Dimension > 0).Select(c => c.Stream + 1).DefaultIfEmpty(0).Max(); - var streams = new (int Start, int Stride)[count]; - foreach (var c in channels.Where(c => c.Dimension > 0)) - { - streams[c.Stream].Stride += 4 * c.Dimension; - } - - var at = 0; - for (var s = 0; s < count; s++) - { - streams[s].Start = at; - at = (at + (streams[s].Stride * vertexCount) + 15) & ~15; - } - - return streams; - } - - /// - /// One channel's values, per vertex, or null when the mesh lacks it. - /// Throws for a format other than float32 or uint32. - /// - public static float[]? Read(ReadOnlySpan data, IReadOnlyList channels, int channel, int vertexCount) - { - if (channel >= channels.Count || channels[channel] is not { Dimension: > 0 } c) - { - return null; - } - - if (c.Format is not (0 or 10)) - { - throw new InvalidDataException($"Vertex channel {channel} has format {c.Format}, which the port doesn't read."); - } - - var (start, stride) = Streams(channels, vertexCount)[c.Stream]; - var values = new float[vertexCount * c.Dimension]; - for (var v = 0; v < vertexCount; v++) - { - for (var i = 0; i < c.Dimension; i++) - { - var at = data[(start + (v * stride) + c.Offset + (4 * i))..]; - values[(v * c.Dimension) + i] = c.Format == 0 ? BinaryPrimitives.ReadSingleLittleEndian(at) : BinaryPrimitives.ReadUInt32LittleEndian(at); - } - } - - return values; - } - - /// A submesh's indices: of them from byte , 16-bit unless . - public static int[] Indices(ReadOnlySpan buffer, bool wide, int firstByte, int count) - { - var indices = new int[count]; - for (var i = 0; i < count; i++) - { - indices[i] = wide - ? (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer[(firstByte + (4 * i))..]) - : BinaryPrimitives.ReadUInt16LittleEndian(buffer[(firstByte + (2 * i))..]); - } - - return indices; - } - - /// - /// A Unity Matrix4x4f, given as e00, e01, e02, e03, e10 … e33 (row, column, for column vectors), as a MonoGame - /// matrix for row vectors: the transpose, with Unity's translation (e03, e13, e23) in M41–M43. - /// - public static Matrix Matrix(ReadOnlySpan e) => - new(e[0], e[4], e[8], e[12], e[1], e[5], e[9], e[13], e[2], e[6], e[10], e[14], e[3], e[7], e[11], e[15]); - - /// Unity's path hash: the standard CRC-32 of the path's UTF-8 bytes. - public static uint PathHash(string path) - { - var crc = 0xFFFFFFFF; - foreach (var b in System.Text.Encoding.UTF8.GetBytes(path)) - { - crc = Crc[(crc ^ b) & 0xFF] ^ (crc >> 8); - } - - return ~crc; - } - - /// - /// Assigns curves to bindings in order: a position or scale takes 3, a rotation 4. Throws - /// for any other binding, or when they don't cover exactly . - /// - /// Each binding's path hash, attribute and type id (4 is Transform). - /// The clip's streamed, dense and constant curves together. - public static ClipBinding[] Bind(IEnumerable<(uint Path, int Attribute, int TypeId)> bindings, int curveCount) - { - var result = new List(); - var curve = 0; - foreach (var (path, attribute, typeId) in bindings) - { - var size = (typeId, attribute) switch - { - (4, 1 or 3) => 3, - (4, 2) => 4, - _ => throw new InvalidDataException($"An animation binding of type {typeId}, attribute {attribute}, which the port doesn't read."), - }; - result.Add(new ClipBinding(path, attribute, curve)); - curve += size; - } - - return curve == curveCount ? [.. result] : throw new InvalidDataException($"Animation bindings cover {curve} curves, but the clip has {curveCount}."); - } -} - -/// -/// A generic AnimationClip's curves: the streamed ones first, then the constants. A streamed key holds from its time -/// until the curve's next key, as the cubic ((a·dt + b)·dt + c)·dt + d in seconds since the key. -/// -internal sealed class AnimationCurves -{ - private readonly (float Time, float A, float B, float C, float D)[][] _streamed; - private readonly float[] _constants; - - private AnimationCurves((float, float, float, float, float)[][] streamed, float[] constants) => (_streamed, _constants) = (streamed, constants); - - /// The number of curves. - public int Count => _streamed.Length + _constants.Length; - - /// - /// Reads the streamed words: frames of a float time and a key count, each key a curve index and four floats. - /// Throws when the clip has dense curves, which the port doesn't read. - /// - public static AnimationCurves Parse(ReadOnlySpan streamed, int streamedCount, int denseCount, float[] constants) - { - if (denseCount > 0) - { - throw new InvalidDataException("An animation clip with dense curves, which the port doesn't read."); - } - - var keys = Enumerable.Range(0, streamedCount).Select(_ => new List<(float, float, float, float, float)>()).ToArray(); - for (var i = 0; i < streamed.Length;) - { - var time = BitConverter.UInt32BitsToSingle(streamed[i]); - var count = (int)streamed[i + 1]; - i += 2; - for (var k = 0; k < count; k++, i += 5) - { - keys[streamed[i]].Add((time, F(streamed[i + 1]), F(streamed[i + 2]), F(streamed[i + 3]), F(streamed[i + 4]))); - } - } - - return new AnimationCurves([.. keys.Select(k => k.ToArray())], constants); - - static float F(uint word) => BitConverter.UInt32BitsToSingle(word); - } - - /// Curve at seconds. - public float Value(int curve, float time) - { - if (curve >= _streamed.Length) - { - return _constants[curve - _streamed.Length]; - } - - // Find the last key at or before the time. Frame times aren't exact multiples of 1/30, so search by time. - var keys = _streamed[curve]; - var (lo, hi) = (0, keys.Length - 1); - while (lo < hi) - { - var mid = (lo + hi + 1) / 2; - (lo, hi) = keys[mid].Time <= time ? (mid, hi) : (lo, mid - 1); - } - - var (at, a, b, c, d) = keys[lo]; - if (at == float.MinValue) - { - return d; - } - - var dt = time - at; - return (((((a * dt) + b) * dt) + c) * dt) + d; - } -} diff --git a/src/PuzzleBoy/ViewCamera.cs b/src/PuzzleBoy/ViewCamera.cs deleted file mode 100644 index 0caccc5..0000000 --- a/src/PuzzleBoy/ViewCamera.cs +++ /dev/null @@ -1,160 +0,0 @@ -using Microsoft.Xna.Framework; -using PuzzleBoy.Rules; - -namespace PuzzleBoy; - -/// -/// The original's follow camera (ROADMAP.md, "M3 findings": camera and controls), advanced one tick at a time. -/// Distances are in cells (the original's 40 units are 1 here), and grid +y runs toward -z, away from the default view. -/// -internal sealed class ViewCamera -{ - // A turn steps 10° a tick for 9 ticks in the normal view and 0.14 rad (8.19°) for 12 in the overhead view, - // snapping to the exact quarter turn on its last tick. - private const float NormalTurnStep = 10f; - private const int NormalTurnTicks = 9; - private const float OverheadTurnStep = 8.19f; - private const int OverheadTurnTicks = 12; - - // The angle change steps the pitch on the press tick and the next 2, and L1/R1 wait one tick more. - private const int AngleSteps = 3; - - // The original's offsets from the character: (0, 400, -240) in the normal view and (0, 450, 0) overhead. - private const float NormalBack = 6f; - private const float NormalHeight = 10f; - private const float OverheadHeight = 11.25f; - - private float _yaw; - private float _targetYaw; - private float _turnStep; - private int _turnTicksLeft; - private Vector3? _orbit; - private bool _overhead; - private int _angle = AngleSteps + 1; - private int _quakeTicks; - private float _quakeX; - private float _quakeY; - - /// R1 presses minus L1 presses, mod 4. It turns the keys and the view together. - public int Mode { get; private set; } - - /// True while an L1/R1 turn runs; the original refuses its □ menu until it ends. - public bool IsTurning => _turnTicksLeft > 0; - - /// True while a turn or an angle change runs; the original ignores L1, R1 and ○ until it ends. - public bool IsBusy => IsTurning || _angle <= AngleSteps; - - /// The view's yaw in degrees: 0 looks toward grid +y, and R1 adds 90 over a turn. - public float Yaw => _yaw; - - /// - /// The grid direction for a screen direction: 0 up, 1 right, 2 down, 3 left. Up is always away from the camera, - /// because the original's key table turns with the view: direction = (key - mode) mod 4. - /// - public Direction DirectionFor(int screen) => (Direction)((screen - Mode + 4) % 4); - - /// L1: turns the view a quarter turn, over 9 ticks in the normal view or 12 overhead. False while busy. - public bool TurnLeft() => Turn(-1); - - /// R1: the opposite of . - public bool TurnRight() => Turn(1); - - /// ○: switches between the normal and overhead views over 3 ticks. False while busy. - public bool ToggleAngle() - { - if (IsBusy) - { - return false; - } - - // The first of the 3 steps shows on the press tick. - _overhead = !_overhead; - _angle = 1; - return true; - } - - /// Starts the 30-tick shake a block's fall ends with. - public void Quake() - { - // The original starts from (320, 800) x 0.01 units, here in cells. - _quakeTicks = 30; - _quakeX = 3.2f / 40f; - _quakeY = 8f / 40f; - } - - /// Advances the turn, angle change and shake by one tick. - public void Tick() - { - if (_turnTicksLeft > 0) - { - _turnTicksLeft--; - _yaw = _turnTicksLeft == 0 ? _targetYaw : _yaw + _turnStep; - } - - if (_angle <= AngleSteps) - { - _angle++; - } - - if (_quakeTicks > 0) - { - _quakeX -= _quakeX / (_quakeTicks + 5); - _quakeY -= _quakeY / (_quakeTicks + 5); - _quakeTicks--; - } - } - - /// The view matrix for a camera following , the point the original orbits. - public Matrix View(Vector3 pivot) - { - // A turn in the normal view orbits the point where it started and doesn't follow the character; the camera - // catches up when it ends. The overhead view keeps following. - if (IsTurning && !_overhead) - { - _orbit ??= pivot; - pivot = _orbit.Value; - } - else - { - _orbit = null; - } - - // How far the view has gone toward overhead: pitch steps 60, 70, 80, 90 and the position moves in thirds. - var toward = Math.Min(_angle, AngleSteps) / (float)AngleSteps; - var overhead = _overhead ? toward : 1 - toward; - var pitch = MathHelper.ToRadians(60 + (30 * overhead)); - var yaw = MathHelper.ToRadians(_yaw); - var away = new Vector3(MathF.Sin(yaw), 0, MathF.Cos(yaw)); - var normal = (away * NormalBack) + (Vector3.Up * NormalHeight); - var position = pivot + Vector3.Lerp(normal, Vector3.Up * OverheadHeight, overhead); - var forward = (-away * MathF.Cos(pitch)) - (Vector3.Up * MathF.Sin(pitch)); - var up = (-away * MathF.Sin(pitch)) + (Vector3.Up * MathF.Cos(pitch)); - - // INFERRED shape: the original's shake is a random walk whose float arguments the decompiler lost; an - // alternating shake of the same size and decay stands in. - if (_quakeTicks > 0) - { - var sign = _quakeTicks % 2 == 0 ? 1 : -1; - position += (Vector3.Cross(forward, up) * _quakeX * sign) + (Vector3.Up * _quakeY * sign); - } - - return Matrix.CreateLookAt(position, position + forward, up); - } - - private bool Turn(int sense) - { - if (IsBusy) - { - return false; - } - - // R1 orbits the camera counterclockwise seen from above (from the south side to the east), L1 the other way. - // The first step shows on the press tick. - Mode = (Mode + sense + 4) % 4; - _targetYaw = _yaw + (sense * 90); - _turnStep = sense * (_overhead ? OverheadTurnStep : NormalTurnStep); - _yaw += _turnStep; - _turnTicksLeft = (_overhead ? OverheadTurnTicks : NormalTurnTicks) - 1; - return true; - } -} diff --git a/tests/PuzzleBoy.Tests/CharacterModelTests.cs b/tests/PuzzleBoy.Tests/CharacterModelTests.cs deleted file mode 100644 index 79a330f..0000000 --- a/tests/PuzzleBoy.Tests/CharacterModelTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.Xna.Framework; -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -// A handmade two-node model: "root" with "placed" under it, the node the game places, as re/m3-jack.md describes. -public sealed class CharacterModelTests -{ - private static readonly AnimationCurves NoCurves = AnimationCurves.Parse([], 0, 0, []); - - private static CharacterData Model(params CharacterMesh[] meshes) => new( - [new("root", -1, Vector3.Zero, Quaternion.Identity, Vector3.One), new("placed", 0, Vector3.Zero, Quaternion.Identity, Vector3.One)], - Placed: 1, Fire: 1, meshes, new Dictionary(), NoCurves, [], LoopTicks: 80, Ramp: new TextureData(1, 1, [[0, 0, 0, 0]])); - - [Theory] - [InlineData(Direction.PlusY)] - [InlineData(Direction.PlusX)] - [InlineData(Direction.MinusY)] - [InlineData(Direction.MinusX)] - public void The_model_front_faces_the_direction_of_its_yaw_a_quarter_cell_down(Direction facing) - { - // The model's front is -y in its own axes and up is +z, before the original's -90 degree pitch. - var model = new CharacterModel(Model()); - var (dx, dy) = facing.Offset(); - - model.Pose(0, 3, 5, 90 * (int)facing); - - AssertNear(new Vector3(3 + dx, -0.25f, -(5 + dy)), Vector3.Transform(new Vector3(0, -1, 0), model.World(1))); - AssertNear(new Vector3(3, 0.75f, -5), Vector3.Transform(new Vector3(0, 0, 1), model.World(1))); - } - - [Fact] - public void A_vertex_shared_by_two_bones_lands_between_them() - { - var mesh = new CharacterMesh(1, [Vector3.Zero], [Vector3.UnitY], [Vector2.Zero], Bones: [0, 1], - BindPoses: [Matrix.CreateTranslation(-1, 0, 0), Matrix.CreateTranslation(-3, 0, 0)], - Influences: [[(0, 0.5f), (1, 0.5f)]], Parts: [], Shadow: false); - var model = new CharacterModel(Model(mesh)); - model.Pose(0, 0, 0, 0); - - var (positions, normals) = (new Vector3[1], new Vector3[1]); - model.Skin(0, positions, normals); - - // Bone 0 (root) sits at the origin and bone 1 (placed) a quarter cell down; each bind pose shifts the vertex. - var expected = (Vector3.Transform(new Vector3(-1, 0, 0), model.World(0)) + Vector3.Transform(new Vector3(-3, 0, 0), model.World(1))) / 2; - AssertNear(expected, positions[0]); - } - - [Fact] - public void Skinning_in_the_bind_pose_matches_drawing_the_mesh_rigidly() - { - // With bind pose = the bone's inverse world, a skinned vertex lands where the rigid mesh would put it. - var model = new CharacterModel(Model()); - model.Pose(0, 2, 1, 90); - var rigid = new CharacterMesh(1, [new Vector3(0.2f, 0.3f, 0.4f)], [Vector3.UnitZ], [Vector2.Zero], [], [], [], [], false); - var skinned = rigid with { Bones = [1], BindPoses = [Matrix.Identity], Influences = [[(0, 1f)]] }; - var both = new CharacterModel(Model(rigid, skinned)); - both.Pose(0, 2, 1, 90); - - var (a, b, n) = (new Vector3[1], new Vector3[1], new Vector3[1]); - both.Skin(0, a, n); - both.Skin(1, b, n); - - AssertNear(a[0], b[0]); - } - - private static void AssertNear(Vector3 expected, Vector3 actual) => - Assert.True(Vector3.Distance(expected, actual) < 1e-4f, $"expected {expected}, got {actual}"); -} diff --git a/tests/PuzzleBoy.Tests/ControlsTests.cs b/tests/PuzzleBoy.Tests/ControlsTests.cs index a8259da..1862ef0 100644 --- a/tests/PuzzleBoy.Tests/ControlsTests.cs +++ b/tests/PuzzleBoy.Tests/ControlsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Xna.Framework.Input; +using PuzzleBoy.Rendering; using PuzzleBoy.Rules; namespace PuzzleBoy.Tests; diff --git a/tests/PuzzleBoy.Tests/GameInstallTests.cs b/tests/PuzzleBoy.Tests/GameInstallTests.cs deleted file mode 100644 index e31ff6e..0000000 --- a/tests/PuzzleBoy.Tests/GameInstallTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace PuzzleBoy.Tests; - -public sealed class GameInstallTests -{ - [Fact] - public void LibraryPaths_reads_every_library_and_unescapes_backslashes() - { - const string Vdf = """ - "libraryfolders" - { - "0" - { - "path" "C:\\Program Files (x86)\\Steam" - "label" "" - "apps" - { - "1149360" "13894738420" - } - } - "1" - { - "path" "/home/player/.local/share/Steam" - } - } - """; - - Assert.Equal([@"C:\Program Files (x86)\Steam", "/home/player/.local/share/Steam"], GameInstall.LibraryPaths(Vdf)); - } - - [Fact] - public void At_returns_null_for_a_folder_without_the_game() => - Assert.Null(GameInstall.At(AppContext.BaseDirectory)); -} diff --git a/tests/PuzzleBoy.Tests/PreservationScopeTests.cs b/tests/PuzzleBoy.Tests/PreservationScopeTests.cs index 7fa3ec1..4b07bee 100644 --- a/tests/PuzzleBoy.Tests/PreservationScopeTests.cs +++ b/tests/PuzzleBoy.Tests/PreservationScopeTests.cs @@ -1,5 +1,6 @@ using System.Reflection.Metadata; using System.Reflection.PortableExecutable; +using PuzzleBoy.Rendering; using PuzzleBoy.Rules; namespace PuzzleBoy.Tests; @@ -50,24 +51,30 @@ public void The_member_scan_finds_the_game_calling_Board_Step() Assert.Contains(nameof(Board.Step), BoardMembersTheGameUses()); } - /// The names of the members the game's assembly references, read from its metadata. + /// + /// The names of the members the game's assembly and the engine's rendering library reference, + /// read from their metadata. + /// private static List BoardMembersTheGameUses() { - using var pe = new PEReader(File.OpenRead(typeof(PuzzleBoyGame).Assembly.Location)); - var metadata = pe.GetMetadataReader(); var names = new List(); - foreach (var handle in metadata.MemberReferences) + foreach (var assembly in new[] { typeof(PuzzleBoyGame).Assembly, typeof(BoardView).Assembly }) { - var member = metadata.GetMemberReference(handle); - if (member.Parent.Kind != HandleKind.TypeReference) + using var pe = new PEReader(File.OpenRead(assembly.Location)); + var metadata = pe.GetMetadataReader(); + foreach (var handle in metadata.MemberReferences) { - continue; - } + var member = metadata.GetMemberReference(handle); + if (member.Parent.Kind != HandleKind.TypeReference) + { + continue; + } - var type = metadata.GetTypeReference((TypeReferenceHandle)member.Parent); - if (metadata.GetString(type.Namespace) == typeof(Board).Namespace && metadata.GetString(type.Name) == nameof(Board)) - { - names.Add(metadata.GetString(member.Name)); + var type = metadata.GetTypeReference((TypeReferenceHandle)member.Parent); + if (metadata.GetString(type.Namespace) == typeof(Board).Namespace && metadata.GetString(type.Name) == nameof(Board)) + { + names.Add(metadata.GetString(member.Name)); + } } } diff --git a/tests/PuzzleBoy.Tests/SoundDataTests.cs b/tests/PuzzleBoy.Tests/SoundDataTests.cs deleted file mode 100644 index efe907c..0000000 --- a/tests/PuzzleBoy.Tests/SoundDataTests.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace PuzzleBoy.Tests; - -public sealed class SoundDataTests -{ - [Fact] - public void Float_samples_become_16_bit_little_endian_and_loud_ones_clamp() - { - Assert.Equal([0, 0, 0xFF, 0x7F, 0x01, 0x80, 0xFF, 0x7F, 0x00, 0x80], SoundData.Pcm16([0f, 1f, -1f, 2f, -3f])); - } -} diff --git a/tests/PuzzleBoy.Tests/TextRendererTests.cs b/tests/PuzzleBoy.Tests/TextRendererTests.cs index 1bc4949..a928829 100644 --- a/tests/PuzzleBoy.Tests/TextRendererTests.cs +++ b/tests/PuzzleBoy.Tests/TextRendererTests.cs @@ -1,4 +1,5 @@ using Microsoft.Xna.Framework; +using PuzzleBoy.Nocturne; namespace PuzzleBoy.Tests; @@ -16,16 +17,6 @@ public sealed class TextRendererTests }, 64, 64, Enumerable.Repeat(fill, 64 * 64).ToArray()); - [Fact] - public void A_materials_edges_come_from_its_floats() - { - var material = Plain with { FaceDilate = 0.2f, OutlineWidth = 0.2f, UnderlayDilate = 0.4f }; - - Assert.Equal(0.3f, material.FaceEdge, 5); - Assert.Equal(0.5f, material.ColourEdge, 5); - Assert.Equal(0.2f, material.UnderlayEdge, 5); - } - [Fact] public void A_texel_on_the_face_edge_is_half_covered_and_full_coverage_is_a_ramp_away() { diff --git a/tests/PuzzleBoy.Tests/TextureDecoderTests.cs b/tests/PuzzleBoy.Tests/TextureDecoderTests.cs deleted file mode 100644 index c3ef26a..0000000 --- a/tests/PuzzleBoy.Tests/TextureDecoderTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace PuzzleBoy.Tests; - -public sealed class TextureDecoderTests -{ - // One DXT1 block: red (0xF800) and blue (0x001F) endpoints, with the 2-bit indices given row by row. - private static byte[] Block(ushort c0, ushort c1, uint indices) => - [(byte)c0, (byte)(c0 >> 8), (byte)c1, (byte)(c1 >> 8), (byte)indices, (byte)(indices >> 8), (byte)(indices >> 16), (byte)(indices >> 24)]; - - [Fact] - public void A_four_color_block_blends_two_thirds_and_one_third() - { - // Pixels 0-3 use indices 0, 1, 2, 3. - var rgba = TextureDecoder.Dxt1(Block(0xF800, 0x001F, 0b11_10_01_00), 4, 4); - - Assert.Equal([255, 0, 0, 255], rgba[0..4]); - Assert.Equal([0, 0, 255, 255], rgba[4..8]); - Assert.Equal([170, 0, 85, 255], rgba[8..12]); - Assert.Equal([85, 0, 170, 255], rgba[12..16]); - } - - [Fact] - public void A_three_color_block_uses_index_3_for_transparent_black() - { - var rgba = TextureDecoder.Dxt1(Block(0x001F, 0xF800, 0b11_10), 4, 4); - - Assert.Equal([127, 0, 127, 255], rgba[0..4]); - Assert.Equal([0, 0, 0, 0], rgba[4..8]); - } - - [Fact] - public void A_level_smaller_than_a_block_keeps_only_its_pixels() - { - var rgba = TextureDecoder.Dxt1(Block(0xF800, 0x001F, 0), 2, 1); - - Assert.Equal(8, rgba.Length); - Assert.Equal(8, TextureDecoder.Dxt1Size(2, 1)); - } - - [Fact] - public void Rgb24_gets_full_alpha() - { - Assert.Equal([1, 2, 3, 255, 4, 5, 6, 255], TextureDecoder.Rgb24([1, 2, 3, 4, 5, 6], 2, 1)); - } -} diff --git a/tests/PuzzleBoy.Tests/TimingTests.cs b/tests/PuzzleBoy.Tests/TimingTests.cs deleted file mode 100644 index a5a4465..0000000 --- a/tests/PuzzleBoy.Tests/TimingTests.cs +++ /dev/null @@ -1,161 +0,0 @@ -using PuzzleBoy.Rules; - -namespace PuzzleBoy.Tests; - -// The original's timing in ticks (ROADMAP.md, "M3 findings": moves, camera and controls). -public sealed class TimingTests -{ - [Fact] - public void A_turn_of_the_view_takes_9_ticks_and_ends_a_quarter_turn_away() - { - var camera = new ViewCamera(); - - Assert.True(camera.TurnRight()); - for (var tick = 1; tick < 9; tick++) - { - Assert.True(camera.IsBusy); - camera.Tick(); - } - - Assert.False(camera.IsBusy); - Assert.Equal(90, camera.Yaw); - } - - [Fact] - public void A_second_turn_or_an_angle_change_waits_for_the_turn_to_end() - { - var camera = new ViewCamera(); - camera.TurnLeft(); - - Assert.False(camera.TurnLeft()); - Assert.False(camera.ToggleAngle()); - Assert.Equal(3, camera.Mode); - } - - [Fact] - public void After_an_angle_change_L1_and_R1_wait_3_ticks() - { - var camera = new ViewCamera(); - - Assert.True(camera.ToggleAngle()); - camera.Tick(); - Assert.False(camera.TurnRight()); - camera.Tick(); - Assert.False(camera.TurnRight()); - camera.Tick(); - Assert.True(camera.TurnRight()); - } - - [Fact] - public void A_turn_in_the_overhead_view_takes_12_ticks_and_still_ends_a_quarter_turn_away() - { - var camera = new ViewCamera(); - camera.ToggleAngle(); - for (var tick = 0; tick < 3; tick++) - { - camera.Tick(); - } - - Assert.True(camera.TurnRight()); - for (var tick = 1; tick < 12; tick++) - { - Assert.True(camera.IsTurning); - camera.Tick(); - } - - Assert.False(camera.IsTurning); - Assert.Equal(90, camera.Yaw); - } - - [Theory] - [InlineData("1..", 4)] - [InlineData("1a.", 5)] - [InlineData("1ao", 9)] - public void The_next_input_waits_4_ticks_after_a_walk_5_after_a_push_and_9_after_a_fall(string drawing, int ticks) - { - var board = Ascii.Board(drawing); - board.Step(Direction.PlusX); - var animation = new MoveAnimation(board.LastMove!, (0, 0)); - - for (var tick = 0; tick < ticks; tick++) - { - Assert.False(animation.Done); - animation.Tick(); - } - - Assert.True(animation.Done); - } - - [Fact] - public void A_slide_shows_a_quarter_on_the_input_tick_and_is_whole_after_3_more() - { - var board = Ascii.Board("1.."); - board.Step(Direction.PlusX); - var animation = new MoveAnimation(board.LastMove!, (0, 0)); - var slides = new List { animation.Slide }; - for (var tick = 0; tick < 4; tick++) - { - animation.Tick(); - slides.Add(animation.Slide); - } - - Assert.Equal([0.25f, 0.5f, 0.75f, 1f, 1f], slides); - } - - [Fact] - public void A_fall_sinks_over_the_4_ticks_after_the_push_and_ends_on_the_8th() - { - var board = Ascii.Board("1ao"); - board.Step(Direction.PlusX); - var animation = new MoveAnimation(board.LastMove!, (0, 0)); - var sinks = new List(); - for (var tick = 0; tick < 9; tick++) - { - sinks.Add(animation.Sink); - Assert.Equal(tick == 8, animation.FallEnds); - animation.Tick(); - } - - Assert.Equal([0f, 0f, 0f, 0f, 0.25f, 0.5f, 0.75f, 1f, 1f], sinks); - } - [Fact] - public void A_fall_starts_on_the_tick_after_the_push_ends() - { - var board = Ascii.Board("1ao"); - board.Step(Direction.PlusX); - var animation = new MoveAnimation(board.LastMove!, (0, 0)); - var starts = new List(); - for (var tick = 0; tick < 9; tick++) - { - if (animation.FallStarts) - { - starts.Add(tick); - } - - animation.Tick(); - } - - Assert.Equal([4], starts); - } - - [Fact] - public void Arriving_at_the_goal_is_signalled_on_the_last_tick_of_the_slide() - { - var board = Ascii.Board(".1."); - board.Step(Direction.PlusX); - var animation = new MoveAnimation(board.LastMove!, (1, 0)); - var arrivals = new List(); - for (var tick = 0; tick < 4; tick++) - { - if (animation.ArrivesAt((2, 0))) - { - arrivals.Add(tick); - } - - Assert.False(animation.ArrivesAt((0, 0))); - animation.Tick(); - } - - Assert.Equal([3], arrivals); - } -} diff --git a/tests/PuzzleBoy.Tests/UnityFormatsTests.cs b/tests/PuzzleBoy.Tests/UnityFormatsTests.cs deleted file mode 100644 index 3636b8f..0000000 --- a/tests/PuzzleBoy.Tests/UnityFormatsTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Buffers.Binary; -using Microsoft.Xna.Framework; - -namespace PuzzleBoy.Tests; - -// Handmade data in the layouts re/m3-jack.md records, so these need no game files. -public sealed class UnityFormatsTests -{ - private static readonly VertexChannel[] Layout = - [ - new(0, 0, 0, 3), new(0, 12, 0, 3), default, default, - new(1, 0, 0, 2), default, default, default, default, default, default, default, - new(2, 0, 0, 2), new(2, 8, 10, 2), - ]; - - [Fact] - public void Streams_are_interleaved_and_each_starts_16_byte_aligned() - { - Assert.Equal([(0, 24), (80, 8), (112, 16)], UnityFormats.Streams(Layout, 3)); - } - - [Fact] - public void Channels_decode_from_their_stream_and_offset() - { - var data = new byte[160]; - BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(24 + 12 + 4), 7.5f); - BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(80 + 8 + 4), 0.25f); - BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(112 + 16 + 8 + 4), 9); - - Assert.Equal(7.5f, UnityFormats.Read(data, Layout, UnityFormats.Normal, 3)![4]); - Assert.Equal(0.25f, UnityFormats.Read(data, Layout, UnityFormats.Uv0, 3)![3]); - Assert.Equal(9f, UnityFormats.Read(data, Layout, UnityFormats.BlendIndices, 3)![3]); - Assert.Null(UnityFormats.Read(data, Layout, 2, 3)); - } - - [Fact] - public void An_unread_vertex_format_throws() - { - VertexChannel[] halfs = [new(0, 0, 1, 3)]; - - Assert.Throws(() => UnityFormats.Read(new byte[16], halfs, UnityFormats.Position, 1)); - } - - [Fact] - public void A_submesh_reads_its_own_indices() - { - byte[] buffer = [0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0]; - - Assert.Equal([3, 4, 5], UnityFormats.Indices(buffer, wide: false, firstByte: 6, count: 3)); - } - - [Fact] - public void A_bind_pose_translation_moves_the_origin() - { - float[] e = [1, 0, 0, 2, 0, 1, 0, 3, 0, 0, 1, 4, 0, 0, 0, 1]; - - Assert.Equal(new Vector3(2, 3, 4), Vector3.Transform(Vector3.Zero, UnityFormats.Matrix(e))); - } - - [Theory] - [InlineData("", 0u)] - [InlineData("123456789", 0xCBF43926u)] - public void Path_hashes_are_standard_crc32(string path, uint hash) - { - Assert.Equal(hash, UnityFormats.PathHash(path)); - } - - [Fact] - public void Bindings_take_3_curves_for_position_and_scale_and_4_for_rotation() - { - var bindings = UnityFormats.Bind([(1, 1, 4), (2, 2, 4), (3, 3, 4)], 10); - - Assert.Equal([0, 3, 7], bindings.Select(b => b.FirstCurve)); - Assert.Throws(() => UnityFormats.Bind([(1, 1, 4), (2, 2, 4), (3, 3, 4)], 11)); - Assert.Throws(() => UnityFormats.Bind([(1, 4, 4)], 3)); - Assert.Throws(() => UnityFormats.Bind([(1, 1, 23)], 1)); - } - - [Fact] - public void Streamed_keys_hold_a_cubic_until_the_next_key() - { - var curves = AnimationCurves.Parse( - [ - Word(float.MinValue), 2, 0, Word(0), Word(0), Word(0), Word(1), 1, Word(0), Word(0), Word(0), Word(5), - Word(0.5f), 1, 0, Word(1), Word(2), Word(3), Word(4), - Word(float.PositiveInfinity), 0, - ], streamedCount: 2, denseCount: 0, constants: [8]); - - Assert.Equal(3, curves.Count); - Assert.Equal(1f, curves.Value(0, 0.25f)); - Assert.Equal(4f, curves.Value(0, 0.5f)); - Assert.Equal(6.125f, curves.Value(0, 1f)); - Assert.Equal(5f, curves.Value(1, 1f)); - Assert.Equal(8f, curves.Value(2, 1f)); - } - - [Fact] - public void A_key_a_float_step_after_another_time_applies_only_from_its_own_time() - { - var later = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(0.1f) + 1); - var curves = AnimationCurves.Parse( - [ - Word(float.MinValue), 1, 0, Word(0), Word(0), Word(0), Word(1), - Word(later), 1, 0, Word(0), Word(0), Word(0), Word(2), - Word(float.PositiveInfinity), 0, - ], streamedCount: 1, denseCount: 0, constants: []); - - Assert.Equal(1f, curves.Value(0, 0.1f)); - Assert.Equal(2f, curves.Value(0, later)); - } - - [Fact] - public void Dense_curves_throw() - { - Assert.Throws(() => AnimationCurves.Parse([], 0, 1, [])); - } - - private static uint Word(float value) => BitConverter.SingleToUInt32Bits(value); -} From 01d2018735d97003c4846c3039c4cf203a5a6979 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 09:23:14 -0500 Subject: [PATCH 11/14] docs: say where rendering and asset reading now live AGENTS.md, the architecture and topic docs point at the engine's PuzzleBoy.Nocturne and PuzzleBoy.Rendering. ROADMAP.md records the decision and M6, with the diff evidence that the moved code is unchanged. --- AGENTS.md | 6 +++--- ROADMAP.md | 15 +++++++++++++++ docs/architecture.md | 22 +++++++++++++++------- docs/reading-the-game.md | 6 +++--- docs/rendering-and-ui.md | 2 +- docs/timing-and-camera.md | 2 +- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7159de..0c4baf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,9 @@ These are the working rules for agents in this repo. Puzzle Boy is a faithful, c ## Code layout -- `external/puzzle-boy-engine`: the shared rules engine, a pinned git submodule. `PuzzleBoy.Rules` has stage parsing (`Stage`, `Cell`) and the rules (`Board`), with no MonoGame and no file I/O. Don't edit it here: a rule change and its test go into the engine repo first, then this repo bumps the pin with `build: bump engine to `. -- `src/PuzzleBoy`: the MonoGame (DesktopGL) game. `OriginalStageOrder` lists the original's 20 stage files in order. `GameInstall` finds the player's Steam install, or takes the game folder as the first argument, and reads bundles with AssetsTools.NET. It has no content pipeline; every asset is read at runtime. -- `tests/PuzzleBoy.Tests`: xunit.v3 on Microsoft Testing Platform, covering the game. The rules tests live in the engine. Tests that need a board draw it as text with `Ascii.cs`; its summary has the legend. Test names read as sentences. +- `external/puzzle-boy-engine`: the shared engine, a pinned git submodule. `PuzzleBoy.Rules` has stage parsing (`Stage`, `Cell`) and the rules (`Board`), with no MonoGame and no file I/O. `PuzzleBoy.Nocturne` finds the install (`GameInstall`) and decodes its bundles. `PuzzleBoy.Rendering` draws the board and Jack (`BoardView`, `CharacterView`, `CharacterModel`), runs the camera and move timing (`ViewCamera`, `MoveAnimation`) and plays `Sounds`. Don't edit them here: a change and its test go into the engine repo first, then this repo bumps the pin with `build: bump engine to `. Community Edition draws its Classic theme with the same code, so a change there changes both games. +- `src/PuzzleBoy`: the MonoGame (DesktopGL) game: the tick loop (`PuzzleBoyGame`), `GameAssets`, the HUD, dialogue, menu and `TextRenderer`. `OriginalStageOrder` lists the original's 20 stage files in order. `Program` finds the player's install through the engine's `GameInstall`, or takes the game folder as the first argument. It has no content pipeline; every asset is read at runtime. +- `tests/PuzzleBoy.Tests`: xunit.v3 on Microsoft Testing Platform, covering the game. The rules, asset-reading and rendering tests live in the engine. Tests that need a board draw it as text with `Ascii.cs`; its summary has the legend. Test names read as sentences. ## Working style diff --git a/ROADMAP.md b/ROADMAP.md index 8c2ebe3..30f0de4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,7 @@ Puzzle Boy is a standalone, cross-platform rebuild of the Puzzle Boy minigame fr - **This port plays the original's 20 stages, and nothing else.** `OriginalStageOrder` replaces `PlayOrder.Files` in the game, and `GameAssets` loads only those files. The C switch, the switch's controls bar and PageUp/PageDown go. PageUp/PageDown predate M4 as a developer shortcut in v0.1.0, but the original has no stage skip, so it goes too. After stage 20 the board stays cleared, as in v0.1.0. - **Every key read as a press is in one table.** `PuzzleBoyGame.Presses` lists them, and `Pressed` throws on any other key. A test pins the table and the direction table. Held keys (the directions and Escape) are read directly, as before, so a key read that way is outside what the tests see. - **The game-data guard skips submodules.** `git ls-files` lists the engine submodule as a directory, which stopped the check. The engine repo runs the same check on its own files. The positive control now adds a submodule to every scratch repo. +- **The presentation is shared, not copied** (the owner's request: Community Edition gets a Classic Nocturne theme built from this port's own code). Bundle reading moves to the engine's `PuzzleBoy.Nocturne` and the board, Jack, camera, move timing and sound to its `PuzzleBoy.Rendering`, verbatim but for namespace, visibility and citations. The HUD, dialogue, menu, `TextRenderer`, `GameAssets`, `OriginalStageOrder` and the tick loop stay here. `PuzzleBoy.Nocturne` uses MonoGame for its math types only, since its records carry them. See M6. - **CI needs the engine on GitHub.** `.gitmodules` names `https://github.com/hazeliscoding/puzzle-boy-engine.git`, which isn't published yet, so CI here can't check out until it is. Local clones override the URL in `.git/config` (AGENTS.md, Commands). ## M0 findings (2026.10.14) @@ -268,6 +269,20 @@ This repo goes back to being a faithful preservation port of the original 20 sta **Done:** against engine `99eaf06`, the build has no warnings, 87 tests pass, format is clean, and the no-game-data check and its positive control pass. A fresh local clone with the submodule URL overridden builds and passes too. Not yet run on GitHub, since the engine isn't published. Playing the 20 stages from the owner's install hasn't been repeated since the split. +## M6: Shared presentation (done 2026.10.15) + +Community Edition's Classic theme draws the original with this port's code, so that code moves to the engine, where both games use it. Nothing about what this port shows, or when, may change. + +- [x] Move bundle reading to the engine's `PuzzleBoy.Nocturne`: `GameInstall` and its `.Text` and `.Character` parts, `UnityFormats`, `TextureDecoder`, and `FontGlyph`, `FontData` and `TextMaterial` out of `TextRenderer.cs`, since `ReadText` builds them. +- [x] Move drawing, sound and timing to the engine's `PuzzleBoy.Rendering`: `BoardView`, `CharacterView`, `CharacterModel`, `ViewCamera`, `MoveAnimation` and `Sounds`. +- [x] Use both here, pinned at engine `03ee710`, and delete the copies. + - Evidence (observed): each moved file diffed against `c3b0382`, the last commit before the move. 12 files, 1,889 lines; 34 lines changed and 4 `using PuzzleBoy.Nocturne;` lines added. Every change is the namespace, `internal` to `public` on a type or member a game uses, or an "M3 findings" citation now naming puzzle-boy's `ROADMAP.md`. No statement, constant or expression changed. + - 36 of the 87 tests moved with the code: Nocturne 19 (GameInstall 2, SoundData 1, TextureDecoder 4, UnityFormats 11, and TextRendererTests' TextMaterial edge test), Rendering 17 (CharacterModel 6, Timing 11). 51 stay here. The `Board.Switch` scan now also reads `PuzzleBoy.Rendering.dll`. + - `PuzzleBoy no-game-here` prints the same missing-game message, from a debug build and from a self-contained win-x64 publish checked the way the release workflow checks it. The published package versions are unchanged: AssetsTools.NET 3.0.5, Fmod5Sharp 3.1.0, Microsoft.Bcl.Memory 9.0.20, MonoGame.Framework.DesktopGL 3.8.6, NVorbis 0.10.5. + - Not checked: a stage drawn from the owner's install. The code is the same, but the screen wasn't compared. + +**Done when** the moved code differs from this port's last copy only in namespace, visibility and citations, no test is lost, and every gate passes. + ## Later - Discord Rich Presence: the stage you're on ("Stage 7 of 20") and time played, with a setting to turn it off. Needs a Discord application, whose name and image come from the brand (no Atlus art), and an MIT client library such as DiscordRichPresence. It does nothing when Discord isn't running. diff --git a/docs/architecture.md b/docs/architecture.md index 9edeffb..0c5a1f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture > [!TIP] -> **TL;DR:** There are two projects, plus the engine. `PuzzleBoy.Rules` holds the game's logic, with no graphics and no file access, so tests drive it with plain bytes. It comes from [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), a git submodule in `external/puzzle-boy-engine` that Community Edition shares. `PuzzleBoy` is the MonoGame app: it reads everything from your install once at startup, then runs the original's 30-tick loop. +> **TL;DR:** There are two projects, plus the engine. The engine, [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), is a git submodule in `external/puzzle-boy-engine` that Community Edition shares. Its `PuzzleBoy.Rules` holds the game's logic, with no graphics and no file access, so tests drive it with plain bytes. Its `PuzzleBoy.Nocturne` reads your install's bundles, and its `PuzzleBoy.Rendering` draws the board and Jack and plays the sounds. `PuzzleBoy` is the MonoGame app: it reads everything from your install once at startup, then runs the original's 30-tick loop and draws the HUD, dialogue and menu. **Read time:** 4 minutes @@ -14,13 +14,20 @@ flowchart LR end subgraph app["PuzzleBoy (MonoGame)"] order["OriginalStageOrder
which files, in what order"] --> ga - gi["GameInstall
finds and reads the bundles"] --> ga["GameAssets
everything, decoded once"] - ga --> loop["PuzzleBoyGame
the tick loop"] - loop --> views["BoardView, CharacterView,
Hud, Sounds"] + ga["GameAssets
everything, decoded once"] --> loop["PuzzleBoyGame
the tick loop"] + loop --> hud["Hud, TextRenderer,
dialogue and menu"] end - subgraph rules["PuzzleBoy.Rules, from the engine submodule"] + subgraph nocturne["PuzzleBoy.Nocturne, from the engine"] + gi["GameInstall
finds and reads the bundles"] + end + subgraph rendering["PuzzleBoy.Rendering, from the engine"] + views["BoardView, CharacterView,
ViewCamera, MoveAnimation, Sounds"] + end + subgraph rules["PuzzleBoy.Rules, from the engine"] stage["Stage
parses 828-byte files"] --> board["Board
steps, pushes, swivels, undo"] end + gi --> ga + loop --> views bundles --> gi ga -- "stage bytes" --> stage loop -- "Step, Undo, Retry" --> board @@ -67,10 +74,10 @@ sequenceDiagram | `Stage` | Parses a stage file, repairing the six damaged ones | | `Board` | The rules: steps, pushes, falls, swivels, the goal, undo and retry. Its switch is Community Edition's; this port never calls it | | `OriginalStageOrder` | The original's 20 stage files, in its order | -| `GameInstall` | Finds the Steam install and reads bundles with AssetsTools.NET | +| `GameInstall` | Finds the Steam install and reads bundles with AssetsTools.NET (engine, `PuzzleBoy.Nocturne`) | | `GameAssets` | Reads and decodes every asset once at startup, in about 2 seconds | | `PuzzleBoyGame` | The tick loop: input, menus, dialogue, stage flow | -| `ViewCamera` | The follow camera, view turns, the overhead view and the quake | +| `ViewCamera` | The follow camera, view turns, the overhead view and the quake (engine, `PuzzleBoy.Rendering`, like the next three and `Sounds`) | | `MoveAnimation` | Replays one move over its ticks | | `BoardView` | Board geometry, glass walls, outlines, the goal and the backdrop | | `CharacterModel`, `CharacterView` | Pose and skin Jack each tick, then draw him with his shadow and glow | @@ -89,5 +96,6 @@ sequenceDiagram - `src/PuzzleBoy/PuzzleBoyGame.cs`: start at `Update`, then `Play`. - `external/puzzle-boy-engine/src/PuzzleBoy.Rules/Board.cs`: start at `Step`. +- `external/puzzle-boy-engine/src/PuzzleBoy.Rendering/BoardView.cs`: start at `Draw`. - `tests/PuzzleBoy.Tests/Ascii.cs`: how tests draw stages as text. - `tests/PuzzleBoy.Tests/PreservationScopeTests.cs`: the checks that keep this port to the original's 20 stages. diff --git a/docs/reading-the-game.md b/docs/reading-the-game.md index a5c175f..0513b5b 100644 --- a/docs/reading-the-game.md +++ b/docs/reading-the-game.md @@ -3,7 +3,7 @@ > [!TIP] > **TL;DR:** The port finds your Steam install, opens the game's Unity bundles with AssetsTools.NET, and decodes everything itself: stage files, DXT1 textures, FMOD Vorbis audio, Jack's skinned model and animation curves, the text tables and a distance-field font. It all happens once at startup, in about 2 seconds, and nothing is ever written back. -**Read time:** 5 minutes · **Code:** `GameInstall*.cs`, `GameAssets.cs`, `TextureDecoder.cs`, `UnityFormats.cs` +**Read time:** 5 minutes · **Code:** `GameAssets.cs`, and `GameInstall*.cs`, `TextureDecoder.cs` and `UnityFormats.cs` in the engine's `PuzzleBoy.Nocturne` ## From your install to the screen @@ -79,6 +79,6 @@ flowchart LR ## Where to look -- `GameInstall.cs`: finding the install, opening bundles, textures and sounds. -- `GameInstall.Character.cs` and `GameInstall.Text.cs`: Jack, the text and the font. +- `GameInstall.cs` (engine, `PuzzleBoy.Nocturne`): finding the install, opening bundles, textures and sounds. +- `GameInstall.Character.cs` and `GameInstall.Text.cs` (engine, `PuzzleBoy.Nocturne`): Jack, the text and the font. - `GameAssets.Read`: everything read at startup, in one place. diff --git a/docs/rendering-and-ui.md b/docs/rendering-and-ui.md index 6193e70..a1a9ab3 100644 --- a/docs/rendering-and-ui.md +++ b/docs/rendering-and-ui.md @@ -3,7 +3,7 @@ > [!TIP] > **TL;DR:** The board is unlit, textured from the game's own atlas, with see-through glass walls and a glowing goal. Jack is skinned on the CPU and toon-shaded. The HUD keeps the original's 1920×1080 layout at any window size, and all text goes through a TextMeshPro distance-field renderer written for the CPU. The finer numbers were checked against gameplay footage. -**Read time:** 5 minutes · **Code:** `BoardView.cs`, `CharacterModel.cs`, `CharacterView.cs`, `Hud.cs`, `TextRenderer.cs` +**Read time:** 5 minutes · **Code:** `Hud.cs`, `TextRenderer.cs`, and `BoardView.cs`, `CharacterModel.cs` and `CharacterView.cs` in the engine's `PuzzleBoy.Rendering` ## Coordinates diff --git a/docs/timing-and-camera.md b/docs/timing-and-camera.md index 9f24318..a56dd23 100644 --- a/docs/timing-and-camera.md +++ b/docs/timing-and-camera.md @@ -3,7 +3,7 @@ > [!TIP] > **TL;DR:** The port runs at the original's 30 ticks a second, measured from gameplay video. A step takes 4 ticks, a push or a turn 5, and a fall 9. Input is read once per tick in a fixed order, and never while a move plays. The camera follows Jack, turns 90° at a time, and keys always follow the view. -**Read time:** 4 minutes · **Code:** `PuzzleBoyGame.cs`, `ViewCamera.cs`, `MoveAnimation.cs` +**Read time:** 4 minutes · **Code:** `PuzzleBoyGame.cs`, and `ViewCamera.cs` and `MoveAnimation.cs` in the engine's `PuzzleBoy.Rendering` ## Where 30 ticks a second comes from From 84c727ccd7e13828df5a68a98a092d93a40c6790 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 11:30:18 -0500 Subject: [PATCH 12/14] build: bump engine to 2a38379 - docs: record versioning by commit SHA and close E3 with its CI runs --- external/puzzle-boy-engine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/puzzle-boy-engine b/external/puzzle-boy-engine index 03ee710..2a38379 160000 --- a/external/puzzle-boy-engine +++ b/external/puzzle-boy-engine @@ -1 +1 @@ -Subproject commit 03ee71082e1ad23877e183bd0b9fe596ebf0e1ca +Subproject commit 2a38379251e28bd8b623fcb7c8c385b641127911 From 950d5ca7ff64a41d7d52cb82aa4d5cbb3a1b8bc2 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 11:31:37 -0500 Subject: [PATCH 13/14] docs: drop the not-yet-published notes now the repos are public The engine and Community Edition are on GitHub, so the submodule fetches from there and CI passes (PR #3 run 38757831224, release start checks 38757945948). M5 is done with the right test count, 51, and Community is described as the minigame expanded. --- AGENTS.md | 2 +- README.md | 4 ++-- ROADMAP.md | 12 ++++++------ docs/keeping-it-honest.md | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c4baf6..dc7745e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ These are the working rules for agents in this repo. Puzzle Boy is a faithful, c ## Commands -- `git submodule update --init` fetches the rules engine into `external/puzzle-boy-engine`. Until the engine is on GitHub, point the submodule at a local clone first: `git config submodule.external/puzzle-boy-engine.url `, then `git -c protocol.file.allow=always submodule update --init`. +- `git submodule update --init` fetches the engine from GitHub into `external/puzzle-boy-engine`. To work against unpushed engine commits, point the submodule at a local clone: `git config submodule.external/puzzle-boy-engine.url `, then `git -c protocol.file.allow=always submodule update --init`. - `dotnet build PuzzleBoy.slnx` builds everything. Warnings are errors. - `dotnet test --solution PuzzleBoy.slnx` runs the tests. They use handmade stages only and never need the game installed. - `dotnet format PuzzleBoy.slnx --verify-no-changes` checks the style CI will enforce. diff --git a/README.md b/README.md index c8dbc4e..87deed1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ In Nocturne, Puzzle Boy sits deep inside a long RPG. You guide Jack-o'-Lantern t This project rebuilds the minigame so it runs on its own on Windows, macOS and Linux, as a record of how the original plays. It reads the stages, text and sounds from your own copy of *Shin Megami Tensei III Nocturne HD Remaster*. -> **Want to play without Nocturne?** [Puzzle Boy Community Edition](https://github.com/hazeliscoding/puzzle-boy-community) is a separate game on the same rules, with its own stages and art. It installs and plays on its own, with no copy of Nocturne. It isn't published yet. +> **Want more than the original 20?** [Puzzle Boy Community Edition](https://github.com/hazeliscoding/puzzle-boy-community) is the Nocturne minigame expanded: new stages, a level editor, level packs, and the bonus stages and switch key that v0.2.0 had. Its Classic theme comes from your own copy of Nocturne, and without one it plays out of the box with its bundled Default theme. It has no release yet. > **Status:** early development. The original's rules are decoded and implemented, and all 20 stages can be played from your install with the original's board look, camera, timing, sound, HUD, dialogue, menu and an animated Jack-o'-Lantern. Solutions recorded in the original clear every stage in the port. See [ROADMAP.md](ROADMAP.md). @@ -38,7 +38,7 @@ The builds aren't signed, so Windows SmartScreen and macOS Gatekeeper may warn b - The 50 stage files in the game's `pazzleboy` asset bundle are small binary grids. Their format is decoded and recorded in the roadmap. - The rules come from static analysis of the game's code with [REA](https://rea.tools) and Ghidra. The analysis reads only the game's unprotected code and does not bypass its anti-tamper protection. - Each rule becomes a test on a handmade stage, and the game is written from those tests, never from decompiler output. -- The rules live in [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), which Community Edition shares. This repo pins it as a git submodule, so clone with `--recurse-submodules`. The engine isn't published yet either. +- The rules live in [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine), which Community Edition shares. This repo pins it as a git submodule, so clone with `--recurse-submodules`. - [The docs](docs/README.md) walk through all of it, a few minutes per topic: the journey, the architecture, the rules, the timing, reading the game's files, the rendering, the bonus stages (now in Community Edition) and the checks. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 30f0de4..eed3172 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,7 +50,7 @@ Puzzle Boy is a standalone, cross-platform rebuild of the Puzzle Boy minigame fr - **Every key read as a press is in one table.** `PuzzleBoyGame.Presses` lists them, and `Pressed` throws on any other key. A test pins the table and the direction table. Held keys (the directions and Escape) are read directly, as before, so a key read that way is outside what the tests see. - **The game-data guard skips submodules.** `git ls-files` lists the engine submodule as a directory, which stopped the check. The engine repo runs the same check on its own files. The positive control now adds a submodule to every scratch repo. - **The presentation is shared, not copied** (the owner's request: Community Edition gets a Classic Nocturne theme built from this port's own code). Bundle reading moves to the engine's `PuzzleBoy.Nocturne` and the board, Jack, camera, move timing and sound to its `PuzzleBoy.Rendering`, verbatim but for namespace, visibility and citations. The HUD, dialogue, menu, `TextRenderer`, `GameAssets`, `OriginalStageOrder` and the tick loop stay here. `PuzzleBoy.Nocturne` uses MonoGame for its math types only, since its records carry them. See M6. -- **CI needs the engine on GitHub.** `.gitmodules` names `https://github.com/hazeliscoding/puzzle-boy-engine.git`, which isn't published yet, so CI here can't check out until it is. Local clones override the URL in `.git/config` (AGENTS.md, Commands). +- **CI checks the engine out from GitHub.** `.gitmodules` names `https://github.com/hazeliscoding/puzzle-boy-engine.git`, public since 2026.10.15. To try unpushed engine work, a local clone can point the submodule at a local engine checkout in `.git/config` (AGENTS.md, Commands). ## M0 findings (2026.10.14) @@ -246,14 +246,14 @@ The stage files the original ships but never plays, after stage 20. See the deci **Done when** every bonus stage that ships has a solution that clears it in the port, and the switch's rules have tests. -## M5: Split into three repos (local gates pass 2026.10.15; CI waits on the engine's publication) +## M5: Split into three repos (done 2026.10.15) -This repo goes back to being a faithful preservation port of the original 20 stages. The rules move to a shared engine, and everything the original never had moves to a standalone Community Edition that needs no Nocturne. +This repo goes back to being a faithful preservation port of the original 20 stages. The rules move to a shared engine, and everything the original never had moves to Community Edition, the minigame expanded, which plays out of the box with its own Default theme and in the original's look with the player's own copy. - **Three peer repos** in one local folder: `puzzle-boy-engine` (the headless rules, `PuzzleBoy.Rules`), `puzzle-boy` (this repo) and `puzzle-boy-community`. Both games use the engine as a pinned git submodule at `external/puzzle-boy-engine`, with project references to its source. No NuGet feed, and no floating `main`: an engine change is committed there first, then each consumer bumps its pin. - **Ownership.** The original 20-file order stays here. The 23-file bonus order, the switch key and stage skipping move to Community, which can import the bonus stages from the player's own install. `Board.Switch()` stays in the engine; this port no longer calls it. The M4 research and its record above stay as they are. - **Baseline** (observed, before any change): HEAD `410fc89`, tags `v0.1.0` and `v0.2.0`, local tag `pre-split-baseline` on `410fc89`. Build clean, 164 tests pass, format clean, and the no-game-data check and its positive control pass. `PlayOrder.Files` is `2, 4, 32, 8, 38, 36, 37, 48, 42, 49, 40, 20, 22, 13, 21, 24, 14, 10, 25, 26`; `PlayOrder.Bonus` is `1, 30, 31, 0, 3, 9, 35, 43, 5, 6, 33, 7, 19, 47, 39, 41, 45, 34, 12, 46, 15, 11, 44`. `re/` is local and untouched. -- The split happens on the `split/three-repos` branch. Nothing is pushed and no GitHub repo is created until the owner says so. +- The split happens on the `split/three-repos` branch. The owner published the engine and Community repos and pushed this branch as draft PR #3 on 2026.10.15; it isn't merged into `main`. - [x] Extract the rules into `puzzle-boy-engine`, with their tests. - The engine holds `PuzzleBoy.Rules` and 86 of the 164 tests (the Cell, Goal, LastMove, Pushing, Stage, Switch, Swivel, Undo and Walking tests), unchanged but for their namespace. `PlayOrder` isn't in the engine. The engine adds a stage authoring API and has 202 tests. @@ -263,11 +263,11 @@ This repo goes back to being a faithful preservation port of the original 20 sta - `Hud.cs` is back to v0.1.0, and `PuzzleBoyGame.cs` differs from v0.1.0 only by the key table and the removed skip. - [x] Update the docs to tell the preservation port apart from Community Edition. - README, AGENTS.md and the docs. `docs/bonus-stages.md` stays as history, with a note that bonus play moved to Community Edition. -- [ ] CI passes on GitHub once the engine is published. +- [x] CI passes on GitHub: PR #3's CI at `01d2018` (run 38757831224: build, tests, format, the guard and its positive control), and the release workflow, run by hand on that commit, started each archive on Windows, Linux and macOS and reached the missing-game message (run 38757945948). No tag, so nothing was published. **Done when** this repo builds and tests against the pinned engine, plays exactly the 20 original stages, and passes every CI gate including the no-game-data check. -**Done:** against engine `99eaf06`, the build has no warnings, 87 tests pass, format is clean, and the no-game-data check and its positive control pass. A fresh local clone with the submodule URL overridden builds and passes too. Not yet run on GitHub, since the engine isn't published. Playing the 20 stages from the owner's install hasn't been repeated since the split. +**Done:** on GitHub (runs above), the build has no warnings, 51 tests pass (87 at the split, then 36 moved to the engine in M6), format is clean, and the no-game-data check and its positive control pass. Playing the 20 stages from the owner's install hasn't been repeated since the split. ## M6: Shared presentation (done 2026.10.15) diff --git a/docs/keeping-it-honest.md b/docs/keeping-it-honest.md index 148513d..f301cd9 100644 --- a/docs/keeping-it-honest.md +++ b/docs/keeping-it-honest.md @@ -49,7 +49,7 @@ flowchart LR - **No game data:** `scripts/check-no-game-data.sh` fails on Unity bundles, FMOD banks, IL2CPP metadata, Atlus flow scripts, stage files outside the tests, and decompiler output. - Its positive control, `check-no-game-data.test.sh`, plants each of those in a scratch repo and expects the check to fail. A check for something's absence needs proof that it can fail. Every scratch repo also holds a submodule, which the check must skip, since the engine repo runs the same check on its own files. -- **The engine is a pinned submodule.** CI checks out `external/puzzle-boy-engine` at the pinned commit and stops with a clear message if it's missing, rather than failing later in restore. Until the engine is published on GitHub, that checkout fails, so CI is blocked; local clones override the URL. +- **The engine is a pinned submodule.** CI checks out `external/puzzle-boy-engine` at the pinned commit and stops with a clear message if it's missing, rather than failing later in restore. It comes from GitHub; to try unpushed engine work, a local clone can point the submodule at a local engine checkout. - **The original's scope.** Tests prove the game loads only the original's 20 stage files, never references `Board.Switch` (a scan of its metadata, with `Board.Step` as the positive control) and has no switch or stage-skip key in its table of pressed keys or its direction table. - **Docs are part of the build.** Every public type and member needs an XML doc comment, or the build fails (CS1591). - **Releases** are self-contained, so players don't need .NET. CI has no game install, so each archive must start on its own OS and reach the "there's no game here" message before the release is created. From e29761bd948de651980ff9a66403368eb718b10d Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Thu, 15 Oct 2026 11:31:38 -0500 Subject: [PATCH 14/14] docs(roadmap): add the cross-repo ledger and where the definition of done stands --- ROADMAP.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index eed3172..efe59c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -283,6 +283,18 @@ Community Edition's Classic theme draws the original with this port's code, so t **Done when** the moved code differs from this port's last copy only in namespace, visibility and citations, no test is lost, and every gate passes. +## Cross-repo ledger (2026.10.15) + +| Repo | Role | Engine pin | +|---|---|---| +| [puzzle-boy-engine](https://github.com/hazeliscoding/puzzle-boy-engine) | Headless rules (`PuzzleBoy.Rules`), plus reading (`PuzzleBoy.Nocturne`) and drawing (`PuzzleBoy.Rendering`) the player's own Nocturne files. Not a game. | — | +| [puzzle-boy](https://github.com/hazeliscoding/puzzle-boy) (this repo, `split/three-repos`, draft PR #3) | The preservation port: the original 20 stages from the player's own copy. | `2a38379` | +| [puzzle-boy-community](https://github.com/hazeliscoding/puzzle-boy-community) | The minigame expanded: new stages, editor, packs, bonus stages; Default theme bundled, Classic theme from the player's copy. | `2a38379` | + +Both games must pin the same engine commit. An engine change lands there first, then both bump with `build: bump engine to `. + +**Definition of done, where it stands:** the engine (E1 to E3) and this port's split (M5, M6) are done, with CI green on GitHub. This port plays only the original 20 (`The_original_order_is_exactly_the_twenty_documented_files`, `The_original_order_holds_no_bonus_file`, `The_preservation_game_never_calls_Board_Switch`, `The_preservation_game_has_no_switch_or_stage_skip_key`). Community's C1 to C4 and C6 are done and C5 (its first release) is open; its own ROADMAP has the per-platform table. Still open: merging PR #3 (the owner's call), replaying the 20 stages from the owner's install since the split, signing, and hand-play on macOS and Linux. + ## Later - Discord Rich Presence: the stage you're on ("Stage 7 of 20") and time played, with a setting to turn it off. Needs a Discord application, whose name and image come from the brand (no Atlus art), and an MIT client library such as DiscordRichPresence. It does nothing when Discord isn't running.