Skip to content

SDK and runtime support for the Strange Terrain (Nokris) strike - #128

Open
Confetti3 wants to merge 10 commits into
stanuwu:masterfrom
Confetti3:strange-terrain-pr
Open

Confetti3 wants to merge 10 commits into
stanuwu:masterfrom
Confetti3:strange-terrain-pr

Conversation

@Confetti3

@Confetti3 Confetti3 commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

This is the engine side of getting Strange Terrain (the Nokris strike) playable start to finish. I wrote the mission against 0.5 and kept hitting things the SDK couldn't express yet, so this PR is everything the script needed that wasn't there. The mission itself is a separate draft PR on SunriseMissions and should wait for this one.

I know the rules say one feature per PR. These pieces all exist for the same strike and I've only tested them together, so I've kept them in one PR but split it into commits that each build on their own: two for the SDK and runtime work, then four for lane-2 decoding and boss health (added 19 Sep, described below). If you'd rather review them separately, say so and I'll break it up along those lines.

This PR is server side only. An earlier version also carried client hooks for Nokris's health gates and death. I've taken those out and will send them as a separate PR stacked on this one (see the end).

What it adds

SDK generation (commit 1)

  • Squads whose spawner names no spawn rule are now emitted instead of dropped. They carry kSquadRequiresSelectedRule and can only be placed with an explicit type-66 rule (squad:place{spawn_rule=}), so nothing that worked before changes. The strike's tunnel knights are authored this way.
  • Squad members keep their authored spawn profile (field 5, lanes 1–4) when it is exact, which multi-actor squads need to spawn with the right profile.
  • Pack version goes 40 → 41 so existing packs regenerate. SDK: read authored dialogue lists, publish cue definitions, raise dialogue cue events #123 also moves to 41, so whichever lands second needs a bump; happy to rebase.

Host and mission runtime (commit 2)

  • slot:set_squad_attachment{source=, spawn_generation=, active=} – points a type-26 hop-on at a squad. The Host tracks ownership so the attachment follows its squad through objective assignments and can't be re-pointed by a stale request. It accepts any type-26 slot and squad that share an object and roster group; nothing is keyed to this strike.
  • slot:set_mission_effect{filter=, enabled=, revision=} and two new set_object_filter arguments (targets, squad). Together these are what actually make shield bubbles and crystal beams render; a bare attachment is accepted by the client but draws nothing.
  • slot:set_volume_active{volume=, active=} – the native type-32 toggle for a type-60 volume.
  • on_event_trigger_state – level-style occupancy for type-30 triggers, with a continuity value so a script can tell a fresh baseline from a real change.
  • A pending mission-seed revision is now pushed from the keepalive pump, and a pending selected-state revision is published while the client is still loading.
  • msg-6 decode: the 80809445 root block is consumed instead of ending the decode as schemaUnavailable, and the attachment (8080954A) and engagement (808094F0) Sense bodies are decoded (engagement only in its empty-list form, which is all I have seen).
  • A trace log level for the per-row roster dumps, which were drowning the log at debug.

Lane-2 decoding (commits 3–5)

Lane 2 went dark the moment the relic Knight died and stayed dark, which is why I originally thought boss health never reached the server. It was three decoder gaps, found from rejected-packet captures and checked against the 86657 readers:

  • Reflected union arms. The native union reader (0x1409F3D60) reads a six-bit selector and calls the ordinary per-type reader from the family table (0x141F94F38). So a scalar arm takes the union field's own zero width and reads no bits, a nested arm (type 1) is a no-op, and a type-34 arm is the normal selected-schema reader. Sunrise refused all three.
  • Type aliases. Types 20, 30 and 31 share type 18's mode-one reader and type 21 uses the nullable-u32 reader. Those rules were copied in three places and incomplete; they now live in one helper used for fields and union arms alike. Type 12 shares RAW64's form.
  • Type 37 (nested custom) is a nested walk of the schema its reader's global names, 808092FF (19-bit real + 5-bit int8). I checked this one offline: two captured updates of known length (304 bits) land exactly.
  • The native anchor pass (0x141713250) detaches every direct child that gets no record in a batch where its parent does. Without that rule the registry kept children the client had already dropped, and the next implicit group misparsed.
  • When writing a batch, a child that is being removed goes out as its own direct element, not inside its parent's group.

One frame that fails to decode is expensive: the transport has already acked it, so any creates inside are lost and every later frame fails on group size with no schema error. That is what the 70–120 drop bursts were.

An earlier version of this work had a retry that re-parsed a failed packet with children excluded or a group trimmed. I removed it. It accepted a wrong parse once (a mis-sized payload also re-parses after a trim), and with the rules above it never fires.

Boss health from lane 2 (commit 6)

  • The damage component 80804BEE replicates one ten-bit level per pool (pool 0 health, pool 1 shield). The codec keeps both on the update payload, the policy lane keys them by the actor's authored source (its squad when the world view maps one), and the mission runtime raises damageState through the same row lookup and event path the Sense levels already use.
  • Nokris's three clamp holds arrive at 880, 614 and 338 of 1023. The first settles one level under 880, so a script should compare with <=, not equality.
  • The combatant_damage changed log line now always carries type= and source=sense|entity.

Notes for review

  • Nothing here pushes a file past 1000 lines (activity_wire_codec_decode_runtime.h was already at 1007 and gains 10). The type-26 and type-32 bodies got their own headers next to mission_effect_auth.h, and the root Sense framing got its own translation unit for that reason.
  • Formatted with the repo .clang-format. I haven't run clang-tidy locally.
  • No game data is included; the only constants are schema tags.

Not in this PR

Nokris has three health gates (86% / 60% / 33%) where he has to stop taking damage until the crystal phase is cleared. With commit 6 the script now sees him cross each gate, and sees his death, from the server side, so I no longer need a client hook to observe health. What still needs the client is holding him at the floor: I haven't found a server-side way to stop a boss taking damage. That work lives on its own branch (strange-terrain-native), stacked on this one. If there's a server-side way to hold a boss at a health floor, I'd much rather use that and drop the hooks.

Without that follow-up, the Nokris shield hop-on also selects no actor. set_squad_attachment itself isn't tied to the strike, but the Kvada first-selection repair it needs there is a client patch too. The strike's boss fight needs the follow-up, so the SunriseMissions draft should wait for it.

Testing

Solo, build 86657, not tested with more than one player.

  • Commits 1–2: I ran the strike in game on this branch and saw no regressions against my earlier working build. That run was just before the final cleanup (formatting, moving two Host classes from headers into .cpp files, and generalising the attachment pair check).
  • Commits 3–6: two full strike runs on 19 Sep with this exact decoder and damage code, on a local build that also carries my client hooks and some diagnostics. Both completed with zero dropped lane-2 frames (about 3,900 batches each; previously 70–120 drops starting at the Knight's death), all three health holds visible server side, and the script reaching its final phase. The second run was after removing the retry. The branch as pushed builds in Release; I have not played this exact binary without the hooks, since the boss fight can't finish without them.

…dies

A union arm is read by the ordinary per-type reader (86657, union reader
0x1409F3D60, family-4 table 0x141F94F38): a scalar arm takes the union field's
own zero width and reads no bits, a nested arm is a no-op, and a type-34 arm is
the selected-schema reader. Type 12 shares RAW64's form. Types 20, 30 and 31
share type 18's mode-one reader and type 21 the nullable-u32 reader; one helper
now applies those aliases to fields and union arms alike. Type 37 is a nested
walk of the schema its reader's global names, 808092FF (24 bits).

Without these, component 80803D3B on the Knight's child dropped its frame, the
creates in that frame were lost, and every later frame failed on group size.
…does

The native anchor pass (0x141713250) runs before its remove pass: an entity that
receives a record detaches to root every direct child that received none in the
same batch. Without it the registry kept children the client had already
dropped from the group, and the next implicit group misparsed.
The client sends a child that is being removed as its own direct element, not
inside its parent's implicit group.
… damageState

The damage component 80804BEE replicates on lane 2 with one ten-bit level per
pool; pool 0 is health, pool 1 shield. The codec keeps the two levels on the
update payload, the policy lane keys them by the actor's authored source (its
squad when the world view maps one) and finds the mission from the session's
policy binding or, without one, from the packet source. The mission runtime
raises damageState through the same row lookup and event path as Sense levels.

On 86657 a bound combatant never republishes Sense, so this is the only source
of boss health: Nokris's clamp holds at 880, 614 and 338 of 1023 arrive here.
@Confetti3
Confetti3 marked this pull request as ready for review September 19, 2026 16:15
@stanuwu

stanuwu commented Sep 27, 2026

Copy link
Copy Markdown
Owner
  • predicates[8] overflows
  • script fault on alive counts nil
  • squads cannot take actor commands
  • stale-request check never fails
  • overall code quality and comment quality can be improved

- Append every type-34 filter predicate through a capacity check; `inside`
  could write predicates[8] once `targets` or `squad` filled the body.
- Keep squad_state alive_count and previous_alive_count integers (zero when
  population_available is false) and removal_flag a boolean, so existing
  scripts that compare them do not fault.
- Let rule-less squads take actor commands: one squad_runnable() check now
  serves placement and both actor-command gates.
- Drop the attachment previousRevision compare, which read the revision from
  the snapshot it was checked against and so could never fail. A request is
  stale when its spawn_generation no longer names the retained placement.
- Squad reports: a forward counter gap that confirms the spawn generation
  keeps the population comparison, a report naming a newer placement is never
  a replay, a consecutive root-absent delta continues the retained levels,
  created counts carry across a proven lifetime, and a cost reset publishes.
- Keep entity damage levels in their own table, stop a recycled object row
  from raising an interaction twice, fill entitySpawned.spawn_generation, and
  treat type 12 as raw 64-bit in the encoder as the decoder does.
- Remove dead and write-only additions (a no-op payload gate, unused fields,
  statuses and aliases), revert unrelated churn, and rewrite comments that
  cited addresses, run notes or content names.
Both sides raised the SDK pack version to 41 for different layouts, so the merged reader takes 42 and rejects packs generated by either.
@Confetti3

Confetti3 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in 8b3a88c:

  • predicates[8]: every type-34 predicate now goes through one capacity check.
  • nil alive counts: alive_count / previous_alive_count stay integers (0 when population_available is false) and removal_flag stays a boolean.
  • actor commands: rule-less squads now pass one shared squad_runnable() check for placement and actor commands.
  • stale-request check: removed the always-true revision compare; staleness is the request's spawn_generation no longer matching the retained placement.
  • quality: removed dead/no-op additions and unrelated churn, rewrote comments that cited addresses or run notes, and fixed a few squad-report continuity edge cases found while auditing.

Also merged current master; upstream took SDK pack versions 41 and 42, so this is now 43.

Upstream's scene event keys took SDK pack version 42, so squad-member spawn profiles move to 43. Relation validation keeps both new checks.
Master now carries squad-member spawn profiles (squad_profiles.h), so this branch drops its duplicate helper, flag name and relation check and uses master's. The pack reader moves to 43 because rule-less squads still change pack content.
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.

2 participants