Skip to content

refactor(actors): per-leader FirstSlave chain — convert 6 walks to O(slaves) (Phase 4)#287

Merged
CoreyRDean merged 2 commits into
developfrom
refactor/per-leader-slave-chain-phase4
May 26, 2026
Merged

refactor(actors): per-leader FirstSlave chain — convert 6 walks to O(slaves) (Phase 4)#287
CoreyRDean merged 2 commits into
developfrom
refactor/per-leader-slave-chain-phase4

Conversation

@CoreyRDean
Copy link
Copy Markdown
Collaborator

Summary

Final phase of the ActorByRNID multi-iteration initiative:

  • Phase 1 (#282) — O(1) sender resolution
  • Phase 3 (#283) — engine-wide FirstOnlinePlayer chain
  • Phase 4 (this PR) — per-leader FirstSlave / NextSlave chain

Replaces 6 For Each ActorInstance / If X\Leader = leader walks with O(slaves-of-this-leader) chain walks. Same shape as Phase 3 but per-leader instead of engine-wide.

Non-technical

When the server needs to find an actor's pets (for AI broadcast, save, /pet command, inventory validation), it used to scan every actor in the world and filter by Leader. After this PR each actor maintains a linked list of its own pets, so finding them is a tight per-leader walk.

Sites converted (6)

File Function/site What it does
Actors.bb WriteActorInstance save loop Flat-file save of all slaves
Actors.bb FreeActorInstanceSlaves Recursive teardown (iterator-during-iteration safe via head-capture)
GameServer.bb Pet-aggro broadcast When player with pets is attacked, pets join the fight
MySQL.bb My_SaveActorInstance Recursive DB save
ServerNet.bb /pet command dispatch Find named pet to command
ServerNet.bb P_InventoryUpdate "S"/"A" validation Is the target one of the sender's pets?

Infrastructure

src/Modules/Actors.bb

  • Field FirstSlave.ActorInstance, Field NextSlave.ActorInstance
  • SlaveLink(Leader, Slave) — head-insert + NumberOfSlaves++. Idempotent; re-links on different-leader assignment.
  • SlaveUnlink(Slave) — walk-to-find-predecessor splice + NumberOfSlaves--. Safe no-op when Slave has no leader.

Lifecycle hooks (4 sites)

All \Leader mutations now go through SlaveLink/SlaveUnlink:

  • Actors.bb ReadActorInstance (load-from-stream)
  • MySQL.bb My_LoadActorInstance (load-from-DB)
  • ScriptingCommands.bb BVM_SETLEADER (BVM script API — both link + unlink paths)

FreeActorInstance now SlaveUnlinks the freed slave defensively, AND clears the freed actor's children's Leader/NextSlave (orphan them rather than leave dangling pointers).

Notable details

Iterator-during-iteration in FreeActorInstanceSlaves

The recursive teardown previously used Restart-on-Delete (For-Each + restart after each child free). Converting to chain walk faces the same hazard because SlaveUnlink mutates A\FirstSlave. Pattern: capture the head BEFORE each step:

While A\FirstSlave <> Null
    Local Child.ActorInstance = A\FirstSlave
    FreeActorInstanceSlaves(Child)
    FreeActorInstance(Child)   ; SlaveUnlinks Child from A's chain
Wend

BlitzForge Local + For-Each collision

GameServer.bb:592's Local A3.ActorInstance = A1\FirstSlave errored "Duplicate variable name" because A3 is implicit-declared at function scope by an earlier For A3 = Each elsewhere in the function. Per the saved memory, renamed to PetCur with an inline comment.

Acceptance criteria

  • 6 walks converted to chain walks
  • 4 Leader-assignment sites use SlaveLink/SlaveUnlink
  • FreeActorInstance maintains chain consistency
  • FreeActorInstanceSlaves uses head-capture pattern (safe under SlaveUnlink)
  • NumberOfSlaves invariant maintained (== chain length always)
  • Full compile clean (Server + Client + GUE + PM + 7 Tools)
  • All 26 tests pass (was 25 + 1 new with 17 cases)

Test plan

  • compile.bat exit 0 (full, no -t)
  • test.bat — 26/26 pass
  • test.bat SlaveChain — 17/17 cases pass standalone

Trade-offs / deferred

  • Phase 2 (FindActorInstanceFromName / FindPlayerFromName name-bucket index) — deferred. The name lookups are human-rate (chat-command parameter resolution), not per-tick/per-packet. Perf payoff is much smaller than Phases 1/3/4.
  • Other unrelated deferred items: 53 packet detail pages, Interface3D Null guards (lower severity), MainMenu SafeWrite, ChatCommand registry refactor, BVM reference prose YAML, CI wiring of --check modes, P_FetchItems cleanup.

🤖 Generated with Claude Code

…slaves) (Phase 4)

Final phase of the ActorByRNID multi-iteration initiative:
* Phase 1 (PR #282) -- O(1) sender resolution via ActorByRNID index
* Phase 3 (PR #283) -- FirstOnlinePlayer chain for engine-wide broadcast loops
* Phase 4 (this PR) -- FirstSlave / NextSlave chain per leader

Replaces 6 `For Each ActorInstance / If X\Leader = leader` walks
with O(slaves-of-this-leader) chain walks. Same shape as Phase 3
but per-leader instead of engine-wide.

## Sites converted (6)

* `src/Modules/Actors.bb` -- WriteActorInstance (flat-file save loop)
* `src/Modules/Actors.bb` -- FreeActorInstanceSlaves (recursive teardown;
  iterator-during-iteration via head-capture)
* `src/Modules/GameServer.bb` -- pet-aggro broadcast (when a player
  with pets gets attacked, the pets join the fight)
* `src/Modules/MySQL.bb` -- My_SaveActorInstance recursive slave save
* `src/Modules/ServerNet.bb` -- /pet command dispatch (find named pet)
* `src/Modules/ServerNet.bb` -- P_InventoryUpdate "S"/"A" pet-validation
  walk (is the target one of the sender's pets?)

## Infrastructure

### src/Modules/Actors.bb

* `Field FirstSlave.ActorInstance` -- head of this actor's slave chain
* `Field NextSlave.ActorInstance` -- next link in leader's chain
* `SlaveLink(Leader, Slave)` -- head-insert + NumberOfSlaves++. Idempotent
  (no-op if Slave already linked to same Leader); re-links if Slave
  was attached to a different leader (detach + reattach).
* `SlaveUnlink(Slave)` -- walk-to-find-predecessor splice on Leader's
  chain + NumberOfSlaves--. Safe no-op when Slave has no leader.

### Lifecycle hooks

All four sites that mutate `\Leader` now go through SlaveLink /
SlaveUnlink:

* `Actors.bb` ReadActorInstance -- load-from-stream
* `MySQL.bb` My_LoadActorInstance -- load-from-DB
* `ScriptingCommands.bb` BVM_SETLEADER -- BVM script API

`FreeActorInstance` was already decrementing NumberOfSlaves; now it
also calls SlaveUnlink to remove the freed slave from its leader's
FirstSlave chain. Additionally, when an actor is freed while it
still has its own slaves, the children are orphaned (Leader = Null,
NextSlave = Null) rather than left with dangling NextSlave pointers
back into freed memory. Typical callers call FreeActorInstanceSlaves
first, but the defensive cleanup catches any path that doesn't.

## Iterator-during-iteration safety in FreeActorInstanceSlaves

The recursive teardown previously used the Restart-on-Delete pattern
because For-Each + FreeActorInstance(child) corrupts the cursor.
Converting to a chain walk faces the same hazard: SlaveUnlink mutates
A\FirstSlave, so we can't use a `Local Cur = A\FirstSlave / Cur = Cur\NextSlave`
cursor. Pattern: capture the head BEFORE each step and re-read each
iteration:

```basic
While A\FirstSlave <> Null
    Local Child.ActorInstance = A\FirstSlave
    FreeActorInstanceSlaves(Child)
    FreeActorInstance(Child)   ; SlaveUnlinks Child from A's chain
Wend
```

Cleaner than the For-Each Restart-on-Delete pattern (no double-loop).

## BlitzForge Local + For-Each collision (gotcha caught at compile)

GameServer.bb:592 converted to `Local A3.ActorInstance = A1\FirstSlave`
initially -- BlitzForge errored with "Duplicate variable name" because
`A3` is implicit-declared at function scope by an earlier
`For A3.ActorInstance = Each` elsewhere in the same function. Per
the feedback_blitz_local_for_each_collision memory, renamed to
PetCur and added an inline comment so the next agent doesn't
rediscover this.

## src/Tests/Modules/SlaveChainTest.bb (17 cases)

Replicated-gate test of the link/unlink semantics + NumberOfSlaves
invariant. Strict-only (no EnableGC) per the
feedback_blitzforge_test_handle_object_gc memory's failure-mode-2
(Type-heavy chain tests stack-overflow under GC tracing).

Coverage:
* Link: single, many head-inserts, idempotent dedup, re-leader,
  Null arg.
* Unlink: head / middle / tail, single element, no-leader no-op,
  Null no-op.
* Multi-leader chain isolation.
* NumberOfSlaves invariant across churn.

## Verification

* `compile.bat` (full) clean -- Server + Client + GUE + PM + 7 Tools.
* `test.bat` -- 26/26 pass (was 25 + 1 new with 17 cases).
* `test.bat SlaveChain` -- 17/17 standalone.

## Closes the multi-phase ActorByRNID initiative (Phase 2 deferred)

Phase 2 (FindActorInstanceFromName / FindPlayerFromName name-bucket
index) remains deferred -- the name lookups are human-rate (chat
command parameter resolution) not per-tick / per-packet, so the
perf payoff is much smaller. Independent follow-up if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CoreyRDean CoreyRDean requested a review from a team as a code owner May 26, 2026 16:36
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5ff6bf5af

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Modules/MySQL.bb
Slave\Leader = A
Slave\AIMode = AI_Pet
If Slave <> Null
SlaveLink(A, Slave)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset slave count before relinking loaded SQL slaves

Calling SlaveLink(A, Slave) here increments A\NumberOfSlaves, but My_LoadActorInstance has already populated that field from the slaves DB column earlier in the same function. For any actor with existing pets, load now double-counts (or further inflates on repeated save/load cycles), breaking the NumberOfSlaves == chain length invariant. This can corrupt persistence paths that still trust the count metadata (e.g., flat-file load reads SavedSlaveCount but save now writes children by traversing FirstSlave), causing record-count mismatches and desynchronized reads.

Useful? React with 👍 / 👎.

…ion)

PR #287 quality-gate reviewer caught a save-corruption bug in the
MySQL load path. The flat-file load path (Actors.bb's ReadActorInstance)
correctly resets NumberOfSlaves to 0 before the SlaveLink loop --
otherwise the saved count would double up with the per-link increments.
The MySQL.bb load path was missing the equivalent reset.

Effect (pre-fix):
1. New character with 0 slaves saves: NumberOfSlaves = 0.
2. Acquires 2 slaves. NumberOfSlaves = 2. Saves: DB writes 2.
3. Logs in. ReadSQLField loads NumberOfSlaves = 2. Slave-row loop
   calls SlaveLink twice -> NumberOfSlaves = 4. Saves: DB writes 4.
4. Logs in. NumberOfSlaves = 4 + 2 = 6. Saves 6.
5. ...continues doubling per login.

The DB column compounds upward indefinitely; the chain itself stays
correct (length 2), but the count diverges, breaking the
`If A1\NumberOfSlaves > 0` guard semantics and any future code that
assumes count == chain length.

## Fix

`MySQL.bb` line 644 area: reset `A\NumberOfSlaves = 0` after reading
the saved value but before the SQL slave-row loop. Same shape as
the Actors.bb fix at line 517.

Also fixes the case-typo `A\NumberOfslaves` -> `A\NumberOfSlaves`
(Blitz field names are case-insensitive so it worked, but
inconsistent with the rest of the codebase).

## Related fix-ups in the same commit

* `Actors.bb` field comment: removed non-existent `BVM_BREAKLINK`
  reference (only `BVM_SETLEADER` exists; it handles both link
  and unlink paths).
* `Actors.bb` `FreeActorInstanceSlaves` header comment: replaced
  stale "Restart-on-Delete" pattern description with the actual
  head-capture pattern + per-leader-chain disjointness rationale.
  The function was converted to chain walk in Phase 4; the header
  comment still described the old For-Each shape.
* `SlaveChainTest.bb`: two new test cases pinning the load-path
  reset invariant. The first deliberately demonstrates the
  pre-fix double-counting bug (saved=2 + 2 links = 4) so a future
  refactor that omits the reset trips the test. The second shows
  the canonical fix shape (reset to 0 before linking) matches the
  chain length.

## Verification

* `compile.bat -t` clean.
* `test.bat SlaveChain` -- 19/19 cases pass (was 17 + 2 new).
* `test.bat` full -- (re-run after CI green; expected 26/26).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CoreyRDean CoreyRDean merged commit 8884305 into develop May 26, 2026
1 check passed
@CoreyRDean CoreyRDean deleted the refactor/per-leader-slave-chain-phase4 branch May 26, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant