EXPcaliburINSIDE THE MACHINE

08 / ABYSS / SURFACE

ABYSS engine: where checks can disagree

ABYSS keeps asking the bot the same thing: prove your work. The bot sends a request, the host copies and checks it, and only then can the Gate run inner work. If the later checks pass, the host can save proof. Only that saved proof can support a short boost. We follow each hand-off, so a surprising result never looks like magic.

Scope: ABYSS engine only. The 41 detector chains and their sustain meters belong to the CLASSIC engine.

Read this first: ABYSS orientation defines the persistent objects. This chapter maps the surfaces. The following Gate, evidence, and effects chapters open them up further.

The four checkpoints, before the byte details

ABYSS has four proof steps, and each raises the ceiling a little higher. Entry gets the host to accept one piece of inner-machine work. Rift then proves that 32 timed calls belong to this run. Chasm turns a write aimed at an old page into checked work on its replacement.

Apex runs a longer program and checks seven results: one live and six comparison results. Each climb needs proof saved by the host, so merely returning a right-looking number does not skip a checkpoint.

ABYSS: four checkpoints to stronger powerABYSS: four checkpoints to stronger powerEach row is one new host-saved proof, not a direct change to EXP.CHECKPOINTWHAT THE BOT ARRANGESWHAT THE HOST ACCEPTSLEVEL LIMIT01ENTRYFirst accepted workSend one 64-byte bodyand finish its pausedEntry work.Read rows and columns;check the saved owner;save Entry proof.402RIFTThis run’s cost trailMake 32 timed SYNCs;recover the run’s root;COMPARE once.Check ordered costs,the root, and Entry;save calibrated proof.803CHASMOld page, new jobQueue a Scratch writebefore maintenance;run the reused page.Check the stale writeand bootstrap work;save dispatch proof.1204APEXThe full inner programPatch six audit answers;run the 30-cell moduleover two calls.Check 505 steps andall six audit answers;save Module proof.16THREE POWER TRACKS · THE BOT REPEATS THIS ROUTE FOR EACH ONESERRATESwordBASTIONSurvival + movementTRAWLGems + pickupShort boost → stronger knight → real fights → collected gems → EXP
Four checkpoints take a proof track from Entry to Apex. Read each row left to right: what the bot arranges, what the host must accept, and the highest level that proof step can support. The host saves a new proof after each successful stage. The bottom shows why the bot repeats the route for sword, shield, and gem collection. View diagram at full size

These levels are limits, not rewards for asking. Entry can support level 4, Rift level 8, Chasm level 12, and audited Apex level 16. The host also counts how many check kinds it accepted. It then uses the lower of that count limit and the proof-step limit. We calculate that rule later. For now, remember that each later step builds on the earlier saved proof.

ABYSS has three separate power tracks. SERRATE helps attack, BASTION helps survival and movement, and TRAWL helps gem supply and pickup.

To reach [16,16,16], the bot needs accepted proof on all three tracks. It must also keep refreshing the short boosts. These levels are not EXP, so the knight must still fight and collect gems.

Two zoom levels: four climbs, with checks inside each one
OVER MANY CALLS AND TICKSThe bot builds a proof history
  1. 1EntryMake acceptable inner work.
  2. 2RiftProve the timed work belongs to this run.
  3. 3ChasmUse a checked page after its slot changes.
  4. 4ApexRun and audit the longer program.
Each climb makes Gate requests. Across those calls, the host may:
INSIDE A CALL, THEN LATER CALLSThe host can stop at any hand-off
  1. 1Copy safe bytes
  2. 2For Entry, read the body two ways
  3. 3Match a saved job and owner
  4. 4Run allowed inner work
  5. 5Check and save proof
  6. 6On a later Drive call, use proof for a short boost

These are hand-offs, not six instructions in every request. Some calls return before inner work; Drive is a separate later request. Passing an early check does not jump straight to Apex or add EXP. The knight still earns EXP by picking up gems.

Locate the boundaries before looking for a weakness

One route to power has six handoffs. Picture the bot handing the game a note: please run this small program, then give me a boost. The game cannot trust that note just because it can read it. It checks the bytes, the owner, the saved job, the inner program's work, and whether that work earns a boost.

The bot owns its program and guest memory. The host (the game code outside the bot) owns the live knight and the records it saves. Inner work can pause and continue in later calls. That is why the host must save more than the final answer.

Follow the two columns below from top to bottom. A row can take another call or game tick, so the diagram shows the route, not one literal request.

ABYSS: who sends it, who saves it, who uses itABYSS: who sends it, who saves it, who uses itRead downward. A later row can happen many calls or ticks after the row above.BOT CAN SENDHOST KEEPS AND CHECKS1 · Request bytesBot writes a frame and input in guestmemory.Saved requestHost checks the range and copies thebytes.↓ later2 · More workBot asks the Gate to continue allowedwork.Saved machineOwner, resource, paused state, andfuel carry on.↓ later3 · Candidate resultInner program finishes or proposes aresult.Proof recordHost checks the work; only a pass addsa receipt.↓ later4 · Power requestBot asks for an attack, survival, orpickup level.Short boostHost checks linked proof and bothlevel limits.THE SHARED ARENA · THIS IS WHERE EXP CAN CHANGEPilot action + boostFight → dropPickup → EXPCopy ≠ proof. Proof ≠ permanent boost. A boost ≠ free EXP.This is the hand-off pattern, not one literal request or a complete exploit trace.
Watch a bot request become a checked, short-lived boost. Left: what the bot can send. Right: what the host copies or saves. The final row returns to ordinary combat and gem pickup. A copied request, accepted proof, active boost, and EXP are four different things. View diagram at full size
Open the six handoffs and what each one checks

The diagram gives you the route. This table names the check at each handoff and what a failure means. A later proof climb can cross several of the same handoffs again.

Step What is happening What the game checks If the check fails
1 · Read the note (guest RAM → wire) Bot supplies a frame, input bytes, and a length Address is safe, fields have the right shape, and bytes are copied The game cannot safely use the request
2 · Read it two ways (wire → Entry) The same saved bytes produce row and column reports Each reading passes its own rules for this run A well-shaped request earns no Entry proof
3 · Find the right saved job (Entry → state) A session and work request point at host-owned records Owner, object version, power track, and operation match A reused slot may point at another object
4 · Run the job (state → executor) The admitted inner instructions process allowed data Instructions stay fixed, data is allowed, and work stays within limits The result cannot count as that admitted work
5 · Check the work (executor → proof) Inner run reports its stop reason, steps, and output The host's checker accepts the required behavior A calculated number earns no receipt by itself
6 · Use a receipt (proof → arena) Saved, owner-bound proof supports a requested boost Earlier links, different proof kinds, owner, space, and final save all pass The boost is rejected or limited to a lower level

To find an exploit, compare the checks on both sides of an arrow. A safe copy says nothing about whether both Entry readers accept the bytes. A return value cannot prove the required work ran. The interesting gap is where the host might treat a weak check as proof of a stronger one. These are guest-machine paths, so they do not give the bot control of native host instructions.

The next five sections follow that route. Surface 1: do both readers accept the copied Entry bytes? Surface 2: does paused work and its cost record belong to this run? Surface 3: does resumed work use the correct resource?

Surface 4: did the inner program perform the required work? Surface 5: how much temporary power can saved proof allow? Each later check still applies.

Surface 1: safe bytes are not accepted Entry work

Start with the bytes. The bot gives the host a memory address (ptr) and a length (len). Together they select a slice of its 64 KiB guest memory. The end address is ptr + len, and that end must not exceed 0x10000.

For example, a 128-byte frame at 0x0100 ends at 0x0180, so it fits. The same frame at 0xFFF0 ends at 0x10070, past the limit, so the host rejects it. The code adds in a wider number type, so an overflowing end cannot wrap back to a small address:

RUST crates/gate/src/abyss/wire.rs · source lines 83–87
pub fn checked_guest_range(ptr: u16, len: usize) -> Option<GuestRange> {
    let start = usize::from(ptr);
    let end = start.checked_add(len)?;
    (end <= GUEST_ADDRESS_SPACE).then_some(GuestRange { range: start..end })
}

After the address check, the host still checks the public fields' shape. But a safe address and valid shape do not prove that the bot did the Entry work.

Next, the host copies the 128-byte frame and any command bytes into its own storage. Why copy? Because the bot can still change its guest memory after the call. The host needs one fixed set of bytes to judge, not a note that changes while it is read.

RUST crates/gate/src/abyss/wire.rs · source lines 327–357
impl OwnedInvocation {
    pub fn copy_from_guest(guest: &[u8], request_pointer: u16) -> Result<Self, StructuralError> {
        let frame = copy_guest_frame(guest, request_pointer)?;
        let decoded = BraidFrame::decode(&frame)?;
        let descriptor = decoded.input_descriptor();
        let range = descriptor
            .checked_range()
            .ok_or(StructuralError::InputRange)?;
        let source = guest
            .get(range.as_range())
            .ok_or(StructuralError::InputRange)?;

        let output = decoded
            .output_descriptor()
            .checked_range()
            .ok_or(StructuralError::OutputRange)?;
        if guest.get(output.as_range()).is_none() {
            return Err(StructuralError::OutputRange);
        }

        let mut input = [0u8; INPUT_MAX];
        input[..source.len()].copy_from_slice(source);
        Ok(Self {
            frame,
            input,
            input_len: descriptor.length,
        })
    }
One request, before and after the host takes its own copy
1 · BOT MEMORYBefore the call

0x0100 holds the 128-byte frame. Its bank byte is 00. The frame points to a 32-byte command at 0x0300; its opcode is 08.

2 · HOST COPYAt the boundary

The host checks both ranges, decodes the frame's public fields, then saves its own frame and command arrays. Its saved bank is 00; its saved opcode is 08.

3 · BOT WRITES AGAINAfter the call

The bot changes its old frame to bank 09 and its old command opcode to FF. The host's saved copy still says bank 00 and opcode 08.

This follows the repository's copy-and-mutate test. The copy fixes which bytes the host checks; it does not say those bytes deserve Entry proof.

Here is the unusual part. The Entry body is 64 bytes: four 4×4 tiles, each like a small crossword grid. RowCursor reads four bytes across a row, while ColumnCursor reads four down a column. Each reader makes sixteen events from the full body, reading the same squares in two directions under separate rules.

RUST crates/gate/src/abyss/braid.rs · source lines 9–11
pub(super) const BODY_LEN: usize = 64;
pub(super) const TILE_COUNT: usize = 4;
pub(super) const RECORDS_PER_TILE: usize = 4;
The same 64 body bytes support two independent readingsThe same 64 body bytes support two independent readingsTile 0body[0..15]Tile 1body[16..31]Tile 2body[32..47]Tile 3body[48..63]Tile 0 enlarged: each cell is one byteb0b1b2b3b4b5b6b7b8b9b10b11b12b13b14b15RowCursor · contiguousFor row 0, read b0, b1, b2, b3. Four rows in eachtile produce row events.ColumnCursor · stride 4For column 0, read b0, b4, b8, b12. Four columnsproduce independent events.Host decisionEach report must satisfy its own checks under one admittedcontext. A safe copy alone proves neither report valid.Four tiles × sixteen bytes = 64. Neither cursor consumes the other cursor’s parsed events.Diagram shows read order, not a byte pattern that satisfies the Entry verifier.
One tile is read across rows and down columns. The highlighted first row and first column share one cell but produce different four-byte records. The other three tiles follow the same layout. This depicts cursor order, not a successful Entry body. View diagram at full size

Label the first tile's bytes b0 through b15. The first row reads b0, b1, b2, b3, while the first column reads b0, b4, b8, b12. Change b1 and you move that row but not that column; change b0 and you move both.

Each reader calculates its own addresses, so the column reader does not reuse the row reader's result:

RUST crates/gate/src/abyss/braid.rs · source lines 115–127
        let tile = self.record / RECORDS_PER_TILE;
        let row = self.record % RECORDS_PER_TILE;
        let base = tile * 16 + row * 4;
        self.record += 1;
        Some((
            tile,
            row,
            [
                self.body[base],
                self.body[base + 1],
                self.body[base + 2],
                self.body[base + 3],
            ],
RUST crates/gate/src/abyss/braid.rs · source lines 152–164
        let tile = self.record / RECORDS_PER_TILE;
        let column = self.record % RECORDS_PER_TILE;
        let tile_base = tile * 16;
        self.record += 1;
        Some((
            tile,
            column,
            [
                self.body[tile_base + column],
                self.body[tile_base + 4 + column],
                self.body[tile_base + 8 + column],
                self.body[tile_base + 12 + column],
            ],

Now use the saved bytes. The Entry fixture holds one 64-byte body for each of 16 banks and each of the three power tracks. The example below is bank 0, SERRATE, and its paired control body fails. Walk through the four steps, because the last one shows why passing the rows is not enough.

Watch the host read real Entry bytes two ways

These bytes are from the saved bank-0 SERRATE witness and its paired failing control. Step through one tile at a time. Each square is one byte; the gold row and blue column are different four-byte records made from the same square grid.

Gold: first rowBlue: first column

Why the first tile passes

The first row is 19 02 03 18. Split the first byte, 19: its high hex digit is 1. For a row starting with high digit 1, the next two bytes must be at most 31. They are 2 and 3, so that rule passes.

The host also XORs all four row bytes: 19 ⊕ 02 ⊕ 03 ⊕ 18 = 00. That matches the first tile's expected value 00. This is what the row loop checks:

RUST crates/gate/src/abyss/braid.rs · source lines 276–282
    for (event_index, (tile, row, bytes)) in RowCursor::new(body).enumerate() {
        let parity = bytes[0] ^ bytes[1] ^ bytes[2] ^ bytes[3] == (q[tile] as u8 & 0x0f);
        let row_ok = match bytes[0] >> 4 {
            0x1 => (bytes[1] <= 31) & (bytes[2] <= 31),
            0x2 => (bytes[1] <= bytes[2]) & (bytes[2] <= 31),
            _ => false,
        };

Now read down the first column: 19 10 20 29. The low hex digit of 19 is 9, which is the required first column operation, Open. The column's XOR is also 00. Row and column share the byte 19, but their other three bytes and operation rules differ. The column loop checks each low digit against a fixed pattern:

RUST crates/gate/src/abyss/braid.rs · source lines 310–319
    for (pc, (tile, column, bytes)) in ColumnCursor::new(body).enumerate() {
        let parity = bytes[0] ^ bytes[1] ^ bytes[2] ^ bytes[3] == (q[tile] as u8 & 0x0f);
        let low_op = bytes[0] & 0x0f;
        let operation = RestrictedOp::from_low_nibble(low_op);
        let operation_valid = operation != RestrictedOp::Invalid;
        parity_valid &= parity;
        skeleton_valid &= operation_valid & (low_op == OP_SKELETON[pc]);
        for lineage in Lineage::ALL {
            signature_matches[lineage.index()] &=
                operands_match(pc, bytes[1] & 0x0f, bytes[2] & 0x0f, lineage);

The expected XOR value comes from the conditioner, a public calculation over the saved 64 raw bytes. It starts at q[0] = 0 and produces a new value after each tile. The first tile uses the low four bits of q[0], so the expected value here is 00.

The last tile uses the low four bits of q[3], which are 07 here. Matching XOR is only one check; the row and column operation rules must also pass.

Why the near-identical control fails

Jump to the last tile. In the accepted body, its four column digits are C D E 8, the required pattern at positions 12–15. The control swaps in a different 16-byte tile.

Its first row still passes the grammar: 2E 0F 14 32 gives 15 ≤ 20 ≤ 31. Its row XOR and first column XOR are still 07, and even the conditioner's final value stays the same. So a shallow checker that stops here would accept the wrong body.

But the control's column digits are E F 4 2, while the host expects C D E 8. Already at position 12, E is wrong; at position 13, F is not even an allowed inner operation. That is enough to fail the separate column check, and the full Entry result needs all checks to pass together:

RUST crates/gate/src/abyss/braid.rs · source lines 254–261
    let conditioner_valid = bank_valid & unique_lineage & (q[4] == selected_seal);
    let valid = rows.parity_valid
        & rows.grammar_valid
        & columns.parity_valid
        & columns.skeleton_valid
        & unique_lineage
        & expected_lineage_valid
        & conditioner_valid;

The fixture test checks this pair for all 16 banks and three tracks. Accepted bodies pass both readers. Their paired controls keep the row and XOR results but fail the column pattern, earning no Entry proof. The reference-bot comparison shows how the swap affects power in the real VM.

The test makes the contrast explicit. The control passes the row and XOR checks, then fails the column pattern and the final Entry decision.

RUST crates/gate/src/abyss/braid.rs · source lines 1240–1246
            let mutant = evaluate_braid(&mutant_body, bank, lineage_u8);
            assert!(mutant.rows.grammar_valid);
            assert!(mutant.rows.parity_valid);
            assert!(mutant.rows.events.iter().all(|event| event.row_valid));
            assert!(mutant.rows.events.iter().all(|event| event.parity_valid));
            assert!(mutant.columns.parity_valid);
            assert!(mutant.columns.events.iter().all(|event| event.parity_valid));
RUST crates/gate/src/abyss/braid.rs · source lines 1258–1263
            assert!(!mutant.columns.skeleton_valid);
            assert_eq!(mutant.columns.lineage_match_count, 0);
            assert!(mutant.derived_lineage().is_none());
            assert!(!mutant.expected_lineage_valid);
            assert!(!mutant.conditioner_valid);
            assert!(!mutant.semantic_valid());

How the reference bot got valid Entry bytes

The bot does not solve a new 64-byte puzzle during a fight. Its author built the answers ahead of time: one body and nonce for each of 16 banks × 3 tracks. When HELLO gives the current bank, the bot selects that bank's records. The next example shows how the author built one tile.

Each byte has two jobs. Across a row, its high hex digit satisfies the row rule. Down a column, its low hex digit spells a column operation or operand. The construction script therefore keeps only the bytes that can do both.

For example, the top row's low digits must spell the required operations. The first column's high digit must be 1 or 2. This code builds the candidate lists:

PYTHON docs/abyss/experiments/converged_braid.py · source lines 419–443
def canonical_cell_domain(
    row: int,
    column: int,
    pc: int,
    lineage: int,
) -> list[int]:
    operands = expected_operands(lineage)
    if row == 0:
        low_values = [OPCODES[pc]]
    elif pc in operands:
        low_values = [operands[pc][row - 1]]
    else:
        low_values = list(range(16))

    high_values = (1, 2) if column == 0 else (0, 1)
    return [
        (high << 4) | low
        for high in high_values
        for low in low_values
    ]

Here is the neat part: the script chooses only the upper-left nine cells of each 4×4 tile. The row and column XOR rules force the other seven. Step through the stages of the saved bank-0 SERRATE tile. Gold cells come from the candidate lists, while the script calculates the cyan, purple, and green cells.

Build one real Entry tile

Bank 0 · SERRATE · first 16 bytes. Start with nine allowed choices. The XOR rule fills the edge. The final step checks why this tile can lead to the next one.

ChosenRow XORColumn XORBoth

Filling the XOR cells is only a candidate. The row grammar, column program, track signature, and conditioner must still pass.

Why can one calculation fill an edge? If four bytes must XOR to 00, the first three determine the fourth. In the first row, 19 ⊕ 02 ⊕ 03 = 18, so the row ends in 18. In the first column, 19 ⊕ 10 ⊕ 20 = 29, so the column ends in 29.

The bottom-right corner must work both ways: 29 ⊕ 00 ⊕ 15 = 3C, and 18 ⊕ 03 ⊕ 27 = 3C. The source fills those edges in exactly that order:

PYTHON docs/abyss/experiments/converged_braid.py · source lines 475–494
        for row in range(3):
            tile[row][3] = (
                parity
                ^ tile[row][0]
                ^ tile[row][1]
                ^ tile[row][2]
            )
        for column in range(3):
            tile[3][column] = (
                parity
                ^ tile[0][column]
                ^ tile[1][column]
                ^ tile[2][column]
            )
        tile[3][3] = (
            parity
            ^ tile[3][0]
            ^ tile[3][1]
            ^ tile[3][2]
        )

This arithmetic guarantees the tile's row and column XORs, but it does not guarantee a valid Entry body. The script still rejects the whole tile if any row breaks its grammar. It also rejects the tile if any column has the wrong operation or track-specific operand. The same happens if the conditioner result would give the next tile the wrong XOR target. The accepted first tile takes q[0] = 00000000 to q[1] = A6C9373D, whose low hex digit is D.

There is a reason the next target must be D. The next tile's required column-operation digits are 4 6 5 A. They occupy its first row, and their XOR is D, so that row forces the target to be D. Across the four tiles, the low-digit targets run 0 → D → 0 → 7. The candidate filter checks the next target before it accepts each tile:

PYTHON docs/abyss/experiments/converged_braid.py · source lines 496–509
        if (
            tile_is_valid(tile, parity, tile_index, lineage)
            and (
                next_parity is None
                or (
                    conditioner_native(q_in, tile, tile_index)
                    & 0x0F
                )
                == next_parity
            )
        ):
            if found == solution_index:
                return tile
            found += 1

The builder repeats this for four tiles, carrying the full 32-bit q forward. For tile zero it takes the bank-numbered valid candidate, meaning the bank-th candidate in the list. For later tiles it takes the first valid candidate. Different power tracks use different operand digits in the column program. The final q[4] is the body's expected seal. This loop builds the tiles:

PYTHON docs/abyss/experiments/converged_braid.py · source lines 517–540
def construct_body(bank: int, lineage: int) -> bytes:
    q = 0
    tiles: list[list[list[int]]] = []
    for tile_index in range(4):
        assert q & 0x0F == REQUIRED_PARITIES[tile_index]
        solution_index = bank if tile_index == 0 else 0
        tile = construct_tile(
            q,
            tile_index,
            lineage,
            solution_index,
            (
                REQUIRED_PARITIES[tile_index + 1]
                if tile_index + 1 < len(REQUIRED_PARITIES)
                else None
            ),
        )
        tiles.append(tile)
        q = conditioner_native(q, tile, tile_index)
    return bytes(
        cell
        for tile in tiles
        for row in tile
        for cell in row

For bank-0 SERRATE, q[4] = 2838D54C. The generator also finds a matching nonce by replaying the limited 31-step first call and its one-step continuation. That nonce is 00002D42, and the bank-and-track tag is 54. The saved fixture stores all three with the 64-byte body.

The script builds all 48 records twice and checks that both runs agree. An independent Z3 model verifies the bodies as well. During the match, the bot simply reads the record selected by HELLO, sends two Entry calls, then requests power. It never runs the search or Z3 inside the match.

A safe 128-byte frame means only that the host can read a stable copy. An accepted row does not imply an accepted column. A body that passes both still needs the right bank, track, owner, and resumed work. Only then does the host save Entry proof.

Observation What it proves What it leaves open
Range accepted The guest address was safe to read Field meaning and owner identity
Frame decoded Public fields have the right shape Entry's deeper checks
Input copied Later guest writes cannot change the saved bytes Whether row and column reports pass
Entry certificate saved The host accepted one bound Entry check Later cost, resource-life, and module checks

What follows: accepted Entry work can earn DualView and EntryLoop receipts for this run's owner. It does not change damage or award EXP. Later steps still have more to do. The certificate section shows why one “passed” bit is not enough.

Surface 2: an accepted entry is not calibrated authority

First, keep the paused work tied to its owner

A final result does not describe all the work. Two programs can return the same value after different steps. Entry can pause, so the host saves where that work stopped in a fibre. The fibre holds the state, step count, and first-call owner. Think of a save-game slot, except the host checks that the right player and run loaded it.

EntryFibreOwner ties the paused work to a session, proof track, bank, nonce, and copied-body hash. When the run resumes, the host checks those same values. A new request with the same final number is not the same work.

What does the host save? The first Entry call leaves a yielded fibre. The host keeps the first-call record, step count, session slot and generation, proof track, bank, nonce, and body hash. The completion call must match these saved values, because this is host state, not a claim from the bot:

RUST crates/gate/src/abyss/state.rs · source lines 74–101

#[derive(Clone, Copy, PartialEq, Eq)]
struct Fibre {
    state: FibreState,
    semantic_steps_total: u16,
    entry_owner: Option<EntryFibreOwner>,
    entry_first: Option<EntryFirstCall>,
}

#[derive(Clone, Copy, PartialEq, Eq)]
struct EntryFibreOwner {
    reserved_slot: u8,
    reserved_generation: u32,
    expected_lineage: u8,
    bank: u8,
    nonce: u32,
    body_digest: [u8; 32],
}

impl Fibre {
    const fn idle() -> Self {
        Self {
            state: FibreState::Idle,
            semantic_steps_total: 0,
            entry_owner: None,
            entry_first: None,
        }
    }
Moment Host state carried forward What a lookalike result would miss
First Entry half admitted First call, paused EntryYielded fibre, owner, body hash A final number cannot identify the first half
Continued run checked First call and all saved owner fields Reusing a slot or similar bytes does not recreate the pause
Entry proof saved Proof node for this owner and track A copied success flag has no saved proof links
Cost check examined Observations in order, run-root comparison, earlier Entry proof One convincing-looking root is not enough

Next, listen to 32 costs without being told the number

The short version. Each run has a secret 96-bit root: twelve bytes, or three 32-bit words. The host turns it into 32 small numbers called classes. Think of 32 notes in a song: the order matters, and humming one note is not the whole song.

Class 0 costs 72 VM steps, and costs rise by 24 steps per class, up to 240 for class 7. The bot must learn all classes in order and recover the root. Then the host compares it with the root for this run.

Rift: follow a hidden cost from the host to a saved receipt
HOST KNOWS
01 · Starting secret12-byte root

One number belongs to this run. The bot is not given it.

02 · Reversible recipe32 ordered classes

The host turns that root into 32 values from 0 to 7.

03 · One SYNCA hidden work charge

Class 7, for example, costs 240 steps. The returned status does not say “240.”

The charge changes where the timed VM program pauses.
BOT LEARNS
04 · Time becomes dataOne of eight resume blocks

That 240-step charge resumes at 0x2033, the block marked class 7.

05 · Repeat on fresh ticks32 classes → candidate root

Record each class in order, then undo the host’s reversible recipe.

06 · Host checks againCOMPARE, then bind

The host checks the whole list, root, and earlier Entry proof. A valid comparison makes a witness; binding it can save CostTranscript and RootBinding receipts.

The pause reveals one class, not the root. The candidate root is still just a guess until the host accepts the full ordered run.

The cost formula is small enough to keep in your head. The larger root-to-song transform comes later:

RUST crates/gate/src/abyss/calibration.rs · source lines 12–18
pub(super) const ROOT_BYTES: usize = 12;
pub(super) const OBSERVATION_COUNT: usize = 32;
pub(super) const COST_CLASS_COUNT: u8 = 8;
pub(super) const COST_BASE: u16 = 72;
pub(super) const COST_STRIDE: u16 = 24;
pub(super) const MAX_OBSERVATION_COST: u16 =
    COST_BASE + COST_STRIDE * (COST_CLASS_COUNT as u16 - 1);
RUST crates/gate/src/abyss/calibration.rs · source lines 130–142
pub(super) const fn cost_for_class(class: u8) -> u16 {
    COST_BASE + COST_STRIDE * ((class & MASK) as u16)
}

pub(super) const fn class_for_cost(cost: u16) -> Option<u8> {
    if cost < COST_BASE || cost > MAX_OBSERVATION_COST {
        return None;
    }
    let delta = cost - COST_BASE;
    if delta % COST_STRIDE != 0 {
        return None;
    }
    Some((delta / COST_STRIDE) as u8)

Here is the catch: the bot does not receive a nice cost = 168 return value. GATECALL keeps only the low status bits as the guest-visible result. The step count is added to the VM's work meter instead. So the bot feels the charge only through where execution pauses, not as data:

RUST crates/vm/src/lib.rs · source lines 1259–1265
                    let packed = host.gatecall(req_ptr, budget, &mut self.mem);
                    let status = packed & GATE_STATUS_MASK;
                    let steps = (packed >> GATE_STEPS_SHIFT).min(GATE_STEPS_MAX);
                    self.push_tagged(status, true);
                    executed = executed
                        .saturating_add(GATECALL_BUDGET_COST)
                        .saturating_add(steps);

Where the host keeps score. On a valid SYNC, the host saves the current class and advances the session position by one. It accepts only one observation per game tick. A second SYNC in that tick still costs work, but it marks the transcript invalid, so repeated calls cannot fill the list. The session holds the position, recorded classes, last tick, invalid flag, and comparison-attempt flag.

RUST crates/gate/src/abyss/state.rs · source lines 3268–3285
    fn synchronize_session(&self, session: &mut Session) -> u16 {
        let ordinal = usize::from(session.synchronization_ordinal.min(31));
        let cost = self.observation_costs[ordinal];
        let eligible = !session.comparison_attempted
            && session.synchronization_ordinal < OBSERVATION_COUNT as u8
            && (!session.has_synchronization_tick
                || session.last_synchronization_tick != self.current_tick);
        if eligible {
            let destination = usize::from(session.synchronization_ordinal);
            session.synchronization_classes[destination] = self.observation_classes[destination];
            session.synchronization_ordinal += 1;
            session.last_synchronization_tick = self.current_tick;
            session.has_synchronization_tick = true;
        } else {
            session.synchronization_invalid = true;
        }
        cost
    }
RUST crates/gate/src/abyss/state.rs · source lines 194–203
    ingress_evidence: bool,
    entry_node: Option<NodeId>,
    synchronization_ordinal: u8,
    synchronization_classes: [u8; OBSERVATION_COUNT],
    last_synchronization_tick: u32,
    has_synchronization_tick: bool,
    synchronization_invalid: bool,
    comparison_attempted: bool,
    calibrated_node: Option<NodeId>,
    preflight_scratch: [u8; 32],

How the Rift bot turns time into a number

The public bot language has no GATECALL command, and it cannot ask the VM where it woke up. Yet Rift needs both: a Gate call to incur the hidden cost, and a way to tell which cost it was. The reference bot gets both by changing its own VM memory while it runs.

The VM keeps instructions and local variables in one writable 64 KiB array. The stock compiler puts two ordinary donor functions at 0x2004 and 0x2026, and places main's 63 local words across 0x2004..0x20ff. When main initializes those locals, its normal stores replace the donor bytes. The first donor then becomes a Gate caller, and the second becomes a 216-byte timer.

How legal source creates a Gate call after the match starts
BEFORE main WRITES · STOCK COMPILER IMAGE
0x2004Donor function AOrdinary compiled bytes
0x2026Donor function BOrdinary compiled bytes
main initializes 63 local wordsThose writes cover the same VM addresses.
AFTER THE STORES · RUNNING GUEST MEMORY
0x2004Gate callerNow contains GATECALL
0x2026216-byte timerEight places where execution can resume

The 51,252-byte uploaded VM image is still byte-for-byte stock compiler output. The changed bytes exist only inside the running guest VM; this does not give the bot control of the host computer.

The generator copies the first donor and changes byte 12 to 0x21, the ABYSS GATECALL opcode. It then joins that caller to the timer. A fit check keeps these bytes inside the local-variable frame. That means main writes them during the run; the uploaded file itself is never patched.

PYTHON bots/exploit/abyss_rift/generate.py · source lines 624–629

    short_gate = bytearray(first)
    short_gate[12] = 0x21
    runtime = bytes(short_gate) + TIMED_ROUTINE
    if GATE_ONCE + len(runtime) > RUNTIME_PATCH_END:
        raise AssertionError("runtime Gate routines exceed main's 63-slot frame")

Each call reaches GATECALL with 306 VM work steps left in the tick. The VM charges a fixed 64 steps plus the hidden class cost. A larger cost uses more of the remaining time, which makes the VM stop the timer sooner and save that stopping address.

It resumes there on the next tick, and eight resume blocks turn those eight addresses into class numbers. For example, class 0 costs 72 steps and resumes at 0x20B8, while class 7 costs 240 and resumes at 0x2033.

Same time left before Gate. Two very different bills.
CLASS 0 · SMALL CHARGE306 − 64 − 72 = 170

More room remains for the timer. In the saved bot, its next-tick wake-up is 0x20B8.

CLASS 7 · LARGE CHARGE306 − 64 − 240 = 2

Almost no room remains. The same timer instead wakes at 0x2033.

What the bot reads: its wake-up address, not a cost number returned by Gate. Blue is the fixed 64-step charge, gold is the class charge, and dark is room left before the surrounding timer instructions spend more work. The generator tuned those instructions; the addresses are measured cut points, not numbers you can obtain by subtracting byte addresses.

Try the other six charges

Pick a class. The host charges a different amount, leaving a different amount of the 306-step window. The bot learns the class from its next instruction position. It does not use the returned status. These are the saved Rift bot’s measured wake-up addresses.

Same 306-step window. A bigger gold charge leaves less room for the timer.

Blue: fixed 64Gold: host costDark: room left
Other instructions also spend work. The dark part is room left after the Gate charge, before those instructions run.
SYNC costs worktick runs outVM wakes at a blockblock returns class

This address map is exact for the saved Rift bot. The addresses count VM bytes, while 306, 64, and the class cost count work steps. They are different rulers, so subtracting addresses will not give the step cost.

The generator tunes the first and later calls separately. Each class block runs the same 24 instructions on its same-tick route, which keeps all 32 observations aligned.

The bot's loop runs a fixed sequence. It waits for a new tick, sets the next SYNC command, and runs the timed caller. The resumed block gives class e, and then the bot refreshes game_tick() before continuing.

Four packed-word writes run on every class. A branch that skipped one write would change the next call's time budget and break the measurement.

BOT bots/exploit/abyss_rift/abyss_rift.bot · source lines 2417–2441
  fresh_tick();
  tick = game_tick();

  while (k < 32) {
    w2 = outer;
    c1 = cmdseq;
    if (k == 0) { pad_first(); } else { pad_next(); }
    e = gate_timed();
    outer = outer + 1;
    cmdseq = cmdseq + 1;

    e = (e + (0 * (k == 0))) & 7;
    d = (5 * (e + 5)) & 7;
    acc = (d - 3*prev - k) & 7;
    i = (5*k) & 31;
    w6 = replace_digit(w6, i, acc, 0);
    w7 = replace_digit(w7, i, acc, 1);
    w8 = replace_digit(w8, i, acc, 2);
    w9 = replace_digit(w9, i, acc, 3);
    prev = d;
    tick = game_tick();
    k = k + 1;
  }

Turn 32 classes back into the run's root

The host did not pick 32 random costs. It starts with one 12-byte root for this run, where twelve bytes hold 96 bits, or 32 three-bit digits. The host scrambles those digits through six reversible rounds and two reversible tables, then maps them to measurable costs. So the bot can walk every step backward after it learns the costs.

Start with the first backward step. A cost gives one class from 0 to 7. That class, the class before it, and its list position reveal one scrambled digit. The position also tells the bot where that digit belongs, since the digits do not arrive in their final order. The widget uses the host's all-zero-root test, a case with a known answer, not a captured hidden match.

Watch 32 costs turn into pieces of a key

This example uses the repository’s all-zero-root test case. The bot makes one observation in each tick. Step forward to see one three-bit piece move into its scrambled slot; the slots fill out of order. Then try the saved control bot’s one-class mistake.

Observed costs, in time order Squares show classes as the bot reads them

Pieces placed by scrambled slot

32 piecesundo lower mixundo upper mixundo six rounds12-byte candidate

At observation 0, the test cost is 240, so its class is 7. There is no previous class yet. The bot calculates d = (5 × (7 + 5)) & 7 = 4, then digit = (4 − 3 × 0 − 0) & 7 = 4.

Here & 7 keeps only three bits, giving a digit from 0 through 7. One cost gives one scrambled digit, not the root. By observation 31, the bot has placed all 32 digits. The source test runs the complete cost sequence and confirms that the full decoder returns twelve zero bytes:

RUST crates/gate/src/abyss/calibration.rs · source lines 324–344
    const ZERO_ROOT_COSTS: [u16; OBSERVATION_COUNT] = [
        240, 168, 120, 240, 96, 72, 72, 144, 144, 168, 216, 240, 192, 72, 168, 144, 240, 168, 120,
        240, 96, 72, 72, 144, 144, 168, 216, 240, 192, 72, 168, 144,
    ];
    const SEQUENTIAL_ROOT_COSTS: [u16; OBSERVATION_COUNT] = [
        240, 168, 144, 240, 192, 120, 240, 120, 216, 216, 96, 216, 168, 120, 96, 96, 72, 168, 72,
        72, 120, 96, 144, 192, 120, 144, 192, 240, 120, 168, 240, 120,
    ];

    #[test]
    fn independent_golden_vectors_pin_symbol_order_and_costs() {
        assert_eq!(encode_costs([0; ROOT_BYTES]), ZERO_ROOT_COSTS);
        assert_eq!(
            encode_costs(std::array::from_fn(|index| index as u8)),
            SEQUENTIAL_ROOT_COSTS
        );
        assert_eq!(decode_costs(&ZERO_ROOT_COSTS), Some([0; ROOT_BYTES]));
        assert_eq!(
            decode_costs(&SEQUENTIAL_ROOT_COSTS),
            Some(std::array::from_fn(|index| index as u8))
        );

Now the bot has 32 scrambled digits, not the root yet. Because the host made them in layers, the bot removes the last layer first, like unpacking a box inside another box. Two layers add values from nearby positions. This four-digit practice uses only one neighbor per digit, while the real 32-digit tables use several weighted neighbors.

A four-digit practice run: add on the way out, subtract on the way back
HOST FORWARD · MAKE A SCRAMBLED CLUE
Start
1234

Four digits before these two mixes

Add the later neighbor
3574

Last digit stays 4

Add the earlier neighbor
3043

5 + 3 = 8, which wraps to 0

BOT BACKWARD · RECOVER THE TABLE INPUT
Scrambled digits
3043

Start after decoding the cost classes

Subtract earlier · left to right
3574

0 − 3 wraps to 5

Subtract later · right to left
1234

7 − 4 = 3; keep going left

All digits wrap into 0..7. The bot works left to right when it needs an earlier recovered digit, then right to left when it needs a later one. The real tables use 32 slots and different weights, but they keep each digit in its own sum. That is why subtraction can recover it.

One layer remains: six swap-and-add rounds. Split the 32 digits into left and right groups of 16. Each forward round moves the old right group to the left. Then it adds a mask made from that old right group to the old left group.

To reverse it, the bot uses the new left group to rebuild the same mask. Then it subtracts that mask from the new right group. The next example uses a mask of 3:

One swap-and-add round, using small practice numbers
HOST GOES FORWARD(left 2, right 5) → (left 5, right 5)

Move old right 5 left. Add mask 3 to old left 2; new right is 5.

BOT GOES BACKWARD(left 5, right 5) → (left 2, right 5)

Current left is old right 5, so rebuild mask 3. Subtract: 5 − 3 = 2.

The real mask reads five positions from the 16-digit right group, plus the round number. It is more involved than 3, but the undo move is the same. Reverse rounds 5, 4, 3, 2, 1, then 0.

Step through the real 32-digit transform below. Its highlighted calculation shows one position, while the boards show the full set. The practice figures explain the direction, and this widget uses the actual host formulas.

Unwrap the 32 scrambled digits

The first decoder placed the digits. Now the bot reverses the remaining operations. This uses the repository’s second public test case: the known root is 00 01 02 … 0B. It is not a hidden match.

Each square is one number from 0 to 7 until the final byte view. Gold marks the example inputs and outputs where a calculation is shown; every position follows the same rule.

The saved bot first undoes the two number tables. Each pass subtracts the values that the matching host pass added. The direction matters: one pass needs earlier recovered digits, and the other needs later ones. This excerpt shows the table undo:

BOT bots/exploit/abyss_rift/abyss_rift.bot · source lines 2443–2476
  i = 0;
  while (i < 32) {
    acc = packed_digit(w6,w7,w8,w9,i);
    j = 0;
    while (j < i) {
      acc = acc - (((3*i + 5*j + 1) & 7) * packed_digit(w6,w7,w8,w9,j));
      j = j + 1;
    }
    acc = acc & 7;
    w6 = replace_digit(w6, i, acc, 0);
    w7 = replace_digit(w7, i, acc, 1);
    w8 = replace_digit(w8, i, acc, 2);
    w9 = replace_digit(w9, i, acc, 3);
    i = i + 1;
  }

  i = 31;
  while (i < 32) {
    acc = packed_digit(w6,w7,w8,w9,i);
    j = i + 1;
    while (j < 32) {
      acc = acc - (((2*i + 4*j + 3) & 7) * packed_digit(w6,w7,w8,w9,j));
      j = j + 1;
    }
    acc = acc & 7;
    w6 = replace_digit(w6, i, acc, 0);
    w7 = replace_digit(w7, i, acc, 1);
    w8 = replace_digit(w8, i, acc, 2);
    w9 = replace_digit(w9, i, acc, 3);
    if (i == 0) { break; }
    i = i - 1;
  }

The final reverse step undoes the six swap-and-add rounds. The bot keeps the digits in temporary words w6–w9. It rebuilds each mask, subtracts it, restores the two groups, and works from round 5 down to round 0:

BOT bots/exploit/abyss_rift/abyss_rift.bot · source lines 2480–2502
  round = 5;
  while (round < 6) {
    i = 0;
    while (i < 16) {
      acc = packed_digit(w6,w7,w8,w9,i);
      acc = acc + 2*packed_digit(w6,w7,w8,w9,(i+1)&15);
      acc = acc + 3*packed_digit(w6,w7,w8,w9,(i+5)&15);
      acc = acc + packed_digit(w6,w7,w8,w9,(i+7)&15)*packed_digit(w6,w7,w8,w9,(i+11)&15);
      acc = (acc + round + 2*i + 1) & 7;
      d = (packed_digit(w6,w7,w8,w9,16+i) - acc) & 7;
      j = (i & 7) * 4;
      if (i < 8) {
        w8 = (w8 & ~(7 << j)) | (d << j);
      } else {
        w9 = (w9 & ~(7 << j)) | (d << j);
      }
      i = i + 1;
    }
    d = w6; w6 = w8; w8 = d;
    d = w7; w7 = w9; w9 = d;
    if (round == 0) { break; }
    round = round - 1;
  }

Now the bot has 32 recovered digits, each from 0 to 7, and each digit needs three bits. Put 32 of them together and you get 96 bits: twelve bytes, or three 32-bit words. The host's root has that same 12-byte shape. The next figure uses four made-up digits so you can watch one cross a byte boundary. It is not a hidden-run root.

Four little digits squeeze into two bytes
DIGIT 0 · BITS 0–25three bits: 1 0 1
DIGIT 1 · BITS 3–52three bits: 0 1 0
DIGIT 2 · BITS 6–86three bits: 0 1 1
DIGIT 3 · BITS 9–111three bits: 1 0 0

The bit positions count upward from left to right. Each digit's three bits are shown from its lowest bit first.

Byte 00x95
0110213041506071
Byte 10x?3
819110011012·13·14·15·

Watch digit 2. Its first two bits fit at positions 6 and 7 of byte 0. Its last bit lands at position 8, the start of byte 1. Byte 0 is fixed as 95. Byte 1 has the low bits 0011, so it reads ?3 in hex until later digits fill its upper four bits. The other 28 digits fill the rest of the 12-byte result.

The code puts digit i at bit i × 3, choosing r0, r1, or r2 by which word owns that position. If a digit crosses a 32-bit boundary, its remaining bits enter the next word. These three words form only a candidate root, because the host still compares all twelve bytes with its own root. Correct packing alone earns no proof.

BOT bots/exploit/abyss_rift/abyss_rift.bot · source lines 2505–2524
  r0 = 0; r1 = 0; r2 = 0;
  i = 0;
  while (i < 32) {
    d = packed_digit(w6,w7,w8,w9,i);
    acc = i * 3;
    j = acc >> 5;
    acc = acc & 31;
    if (j == 0) {
      r0 = r0 | (d << acc);
    } else if (j == 1) {
      r1 = r1 | (d << acc);
    } else {
      r2 = r2 | (d << acc);
    }
    if (acc > 29) {
      if (j == 0) { r1 = r1 | (d >> (32-acc)); }
      else if (j == 1) { r2 = r2 | (d >> (32-acc)); }
    }
    i = i + 1;
  }

Compare once, then bind the proof

The saved bot first completes Entry on all three tracks. It then collects 32 SYNC observations in session 0, one per new Gate tick. Next it sends one COMPARE with the three recovered root words. It binds the resulting run witness into sessions 0, 1, and 2. Then it keeps requesting [RUN, DRIVE [8,8,8]] while the pilot fights. This ordering matters: a later power request cannot replace a missing transcript or comparison.

BOT bots/exploit/abyss_rift/abyss_rift.bot · source lines 2526–2542
  fresh_tick();
  w2 = outer;
  c0 = 2;
  c1 = cmdseq;
  c4 = r0; c5 = r1; c6 = r2; c7 = 0;
  gate_once();
  outer = outer + 1;
  cmdseq = cmdseq + 1;

  fresh_tick();
  w2 = outer;
  c0 = 3;
  c1 = cmdseq;
  gate_once();
  outer = outer + 1;
  cmdseq = cmdseq + 1;

The host does not trust the three words merely because the bot's math produced them. COMPARE checks the whole story:

  1. Was this the first try? This session gets one comparison attempt.
  2. Were all 32 classes collected properly? They must be in order, match the host's list, and have no invalid retry.
  3. Is this the right root? The host compares all 12 bytes with its own root for this run.
  4. Did a safe request enter Gate? The session must have the host's ingress evidence.
  5. Is the earlier Entry proof here? It must be the exact Entry record for this session's line of work.

The host sets comparison_attempted even when the check fails, so sending a corrected number afterward cannot repair that session.

RUST crates/gate/src/abyss/state.rs · source lines 3419–3444
        let candidate = root_from_words(candidate_words);
        let comparison = compare_root(&candidate, &self.roots.owner_root());
        let transcript_complete = session.synchronization_ordinal == OBSERVATION_COUNT as u8
            && !session.synchronization_invalid
            && bytes_difference(session.synchronization_classes, self.observation_classes) == 0;
        let digest = cost_digest(session.synchronization_classes);
        let run = run_witness(self.roots.owner_root(), digest);
        let lineage = normalized_session_lineage(session);
        let entry = normalized_drive_proof(provenance, session.entry_node);
        let exact_entry =
            entry.difference(NodeKind::EntryProof, Scope::Lineage(lineage), 0x03, 4) == 0;
        let create = !session.comparison_attempted
            && transcript_complete
            && comparison.matches()
            && comparison.compared_bytes() == ROOT_BYTES as u8
            && session.ingress_evidence
            && exact_entry;

        session.comparison_attempted = true;
        let retained_witness = *witness;
        let candidate_witness = Some(CalibrationWitness {
            owner_root: candidate,
            cost_digest: digest,
            run_witness: run,
        });
        let install_witness = create && retained_witness.is_none();

On success, the host saves CalibrationWitness { owner_root, cost_digest, run_witness }. CostTranscript records that the ordered work happened, and RootBinding ties it to this run. These are host-made receipts, not names the bot can write into guest memory.

RUST crates/gate/src/abyss/state.rs · source lines 209–214
#[derive(Clone, Copy, PartialEq, Eq)]
struct CalibrationWitness {
    owner_root: [u8; ROOT_BYTES],
    cost_digest: [u8; 16],
    run_witness: [u8; 16],
}

Now change one reading. In the widget, select Misread first class. The first host cost stays 240, or class 7, but the example bot calls it class 0: (7 + 1) & 7. This changes the first decoded piece.

It also changes the next piece, which uses the previous mixed value, while the other 30 readings stay the same. This mirrors the saved control bot's single changed line:

BOT bots/exploit/abyss_rift/abyss_rift_t7_wrong_symbol.bot · source lines 2425–2433
    e = gate_timed();
    outer = outer + 1;
    cmdseq = cmdseq + 1;

    e = (e + (1 * (k == 0))) & 7;
    d = (5 * (e + 5)) & 7;
    acc = (d - 3*prev - k) & 7;
    i = (5*k) & 31;

The arithmetic cannot flag a decoded number as wrong, because every valid 32-class list decodes to some 12-byte number. The source tests this at every position. It changes one class, decodes the new list, and checks the number reproduces that changed list:

RUST crates/gate/src/abyss/calibration.rs · source lines 370–380
    #[test]
    fn every_one_symbol_mutant_reencodes_exactly_without_a_local_oracle() {
        let source = encode_costs(std::array::from_fn(|index| (index * 7) as u8));
        for ordinal in 0..OBSERVATION_COUNT {
            for delta in 1..COST_CLASS_COUNT {
                let mut mutant = source;
                let class = class_for_cost(mutant[ordinal]).expect("encoded class");
                mutant[ordinal] = cost_for_class(class.wrapping_add(delta));
                let plausible = decode_costs(&mutant).expect("every class vector is valid");
                assert_eq!(encode_costs(plausible), mutant);
            }

This is why the host's one-shot COMPARE matters: there is no retry after a wrong compare. The control bot's new number is well-formed, but it is not this run's root. The comparison fails and leaves the control at Entry power [4,4,4].

The correct Rift bot reaches [8,8,8]. Their recorded hidden median scores are 11,341 and 69,071 EXP. These repository measurements do not promise future scores.

The Gate fuel section shows the tick, call, and paused-program work limits. The proof section explains why accepted cost work raises a power limit but cannot skip later resource and module checks.

Surface 3: make a queued write land on the next object

The goal: move from Rift's level 8 to Chasm's level 12. The bot needs a checked program page for each of its three ABYSS sessions. It gets one by making a queued write outlive the object it was approved for.

First, meet the two objects. Scratch is a temporary page the bot may write. Dispatch is a page the host can run, but the bot normally cannot write it. A slot is a numbered place in the host's object table.

A generation is a version number. Slot 3 with Scratch at generation 1 differs from slot 3 with Dispatch at generation 257. The two objects can use the same physical page at different times.

Now the trick. Gate checks a full Scratch handle when the bot queues a write, then pauses before it uses that handle. Maintenance then replaces Scratch with Dispatch. When the queue resumes, it compares only the last generation byte.

It also reuses the saved Scratch write right. Both generations end in 01, so the old write reaches Dispatch. Think of a coat-check ticket that remembers locker 3 and the last claim digit. It then accepts a different coat with a matching digit. Gate must still check the new page and its bootstrap, because this write alone does not earn level-12 proof.

The first pass, frozen at the moment that matters
BEFORE · TICK 2217Old object: Scratch
Slot
3
Full generation
0x00000001
Physical page
same page
Permission
read + write
This is the object whose complete handle passes the first check.
SAVED AT STEP 252Copied queue ticket
Slot
3
Generation kept
last byte: 01
Cached kind
Scratch
Cached right
write
The remaining checks pause at step 256. This ticket waits for the next tick.
AFTER · TICK 2218New object: Dispatch
Slot
3
Full generation
0x00000101
Physical page
same page
Permission
read + execute
Maintenance reused the location and page, but this is a different object.

The mismatch: full generation 1 ≠ 257, but the stored last byte still says 01 = 01. The new Dispatch page has no normal write right. At step 258 the resumed queue uses the old cached Scratch write right to change it anyway.

This gives the bot a write on Dispatch, not a boost. Gate must still record the object reuse, issue a new checked execution, run the nine-cell bootstrap, and accept its proof. In the timeline below, set preflight to 61 and the write finishes before maintenance, so it reaches Scratch instead.

One slot, two identities

Step through the saved Chasm bot’s three queue windows. The blue card shows the planned or saved queue ticket. The gold card shows which object is in the slot now. Watch the full generation change while its last byte stays 01.

Queue request / ticket
Slot at this moment

1. Save a valid Scratch write

The bot first earns Rift's CalibratedProof, because the later Chasm check has no parent without it. Next it must predict when Gate will clean up temporary pages, and which slot that cleanup will turn into Dispatch. Think alarm clock plus seating chart.

Both values come from one 12-byte relocation root. The host makes it by hashing the owner root and the exact upload digest. Because the bot knows its own file and recovered the owner root in Rift, it can reproduce the same schedule and slot order.

One root answers two planning questions
KNOWN AFTER RIFTRelocation rootTAG_RELOCATION + owner root + raw-upload digest → first 12 SHA-256 bytes
THE ALARM CLOCKWhen is cleanup due?

Four root bytes choose a phase. Once maintenance is enabled, Gate cleans up on ticks where tick % 257 equals that phase.

THE SEATING CHARTWhich slot wins?

The root and the next epoch shuffle all 32 slot numbers. Gate walks that order and picks the first slot that held Scratch.

Bot's movePut its target Scratch page and decoy Scratch pages in the predicted slots. Queue the write on the tick just before cleanup.
First pass · 8 Scratch pages
3171221423210
Second pass · 7 Scratch pages
22142524097
Third pass · 6 Scratch pages
1626204824

Gold is the target; the other numbers are decoys. These exact allocation targets come from the saved positive artifact, not fixed Gate constants. Earlier passes leave durable ProgramPages behind, so fewer physical pages remain for temporary Scratch objects. A correct clock with the wrong epoch's seating chart still picks the wrong page.

Cleanup removes temporary Scratch and Dispatch objects and advances the epoch. It then rebuilds one eligible old Scratch slot as Dispatch on the same physical page. The root's phase repeats every 257 ticks, but cleanup starts only after maintenance is enabled. With that timing known, the bot prepares a Scratch write for the tick just before that rebuild.

The old handle is valid when ENQUEUE runs. In the first pass it names slot 3, generation 1, Scratch with write rights, plus the queue's owner session. The request writes four bytes at offset 72, with data 0x43584100 and preflight count 62.

For later sessions the data ends in 01 and 02, and the old generations are 257 and 513. The bot sends ENQUEUE as the first Gate work of the tick just before maintenance. Another Gate call earlier in that tick would block the insertion.

RUST crates/gate/src/abyss/capability.rs · source lines 254–263
pub(super) struct QueueEntry {
    op: u8,
    slot: u8,
    reuse_low8: u8,
    lineage: u8,
    session_slot: u8,
    cached_kind: u8,
    cached_rights: u8,
    payload: [u8; 9],
}

QueueEntry is 16 bytes. Notice reuse_low8, cached_kind, and cached_rights. When the host builds it, the cast slot.generation as u8 keeps only the low eight generation bits. The full handle was checked at enqueue, and a private origin record saves it. But the resume path consults this shorter queue entry first.

RUST crates/gate/src/abyss/capability.rs · source lines 277–292
    fn from_request(request: EnqueueRequest, slot: CapSlot, session: SessionIdentity) -> Self {
        let mut payload = [0u8; 9];
        payload[0..2].copy_from_slice(&request.byte_offset.to_le_bytes());
        payload[2] = request.length;
        payload[3..7].copy_from_slice(&request.data.to_le_bytes());
        payload[7] = request.preflight_count;
        Self {
            op: QUEUE_OPCODE,
            slot: request.handle.slot,
            reuse_low8: slot.generation as u8,
            lineage: session.lineage,
            session_slot: session.slot,
            cached_kind: slot.kind,
            cached_rights: slot.rights,
            payload,

The reference generator writes these exact request fields, where r2 is the predicted target slot. This excerpt shows the saved construction step, not a hypothetical payload:

PYTHON bots/exploit/abyss_chasm/generate.py · source lines 4447–4459
    wait_until(lines, f"({maintenance_expr})-1")
    active_frame(lines, lineage)
    command(
        lines,
        command_base=0,
        opcode=5,
        lineage=lineage,
        sequence=sequence_var,
        target="r2",
        generation=f"0x{old_generation:08x}",
        args=("72", "4", f"0x435841{lineage:02x}", "62"),
    )
    gate_call(lines, sequence_var)

2. Copy before the pause; use after it

Why choose 62? A Gate call can use 256 work steps. Queue processing spends 64 steps on its prefix and three per preflight item. With 62 items that is 64 + 3 × 62 = 250.

Two more steps save a copy of the 16-byte entry, reaching 252. Four checks then run through 256, while the page-address check and write are steps 257–258, after the pause. On the next world tick, maintenance runs before opcode 0x09 resumes the queue.

With 61, processing uses 64 + 3 × 61 = 247, the copy ends at 249, and the write lands at 255. That is before maintenance, so it really writes the old Scratch page and cannot make a stale Dispatch proof. This is a classic debugging trap. A write that succeeds is not the same as a write that crossed the object change.

Chasm: three extra work steps change which page gets writtenChasm: three extra work steps change which page gets writtenZoom in on the end of one 256-step Gate call. Both requests began on valid Scratch.OLD TICK · Scratch pageNEXT TICK · Dispatch61 checkspreflight countWrite finishes before the page changes.62 checkspreflight countCopy survives the pause; write lands after the change.copy saved249WRITE · Scratch255Nothing pendingafter maintenancecopy saved252PAUSE256WRITE258new DispatchThe only input change is 61 → 62. That adds 3 work steps and moves the final write from 255 to 258.Maintenance happens between the paused call and its resumed work; the old and new pages share one slot.
The extra preflight moves one write past the tick boundary. This zoomed timeline shows the last work steps of the two calls. At 61, the write finishes on Scratch at step 255. At 62, the call pauses after step 256; maintenance changes the page to Dispatch before the resumed write at step 258. Spacing between marks is schematic; the step labels are exact. View diagram at full size
Preflight n Prefix + preflight Copy complete Final use Object receiving the write
61 247 249 255 Old Scratch, before maintenance
62 250 252 258 New Dispatch, after maintenance

The source really separates its copy and dereference phases. The second copy step sets pending_copied, records the current epoch, and removes the FIFO head. The low-generation check happens later:

RUST crates/gate/src/abyss/capability.rs · source lines 2512–2535
        let mut copy_second = retained_work;
        for (index, byte) in entry_bytes[8..].iter().copied().enumerate() {
            copy_second.pending[8 + index] = byte;
        }
        copy_second.pending_copied = true;
        copy_second.copied_epoch = self.epoch;
        let mut copy_second_queue = retained_queue;
        let (popped_entry, popped_origin, popped_present) = copy_second_queue.pop_front_fixed();
        copy_second.dereference_valid = popped_present
            & (queue_entry_difference(popped_entry, retained_work.entry) == 0)
            & (queue_origin_difference(popped_origin, retained_work.origin) == 0);
        copy_second.phase = QueuePhase::DerefLive.as_u8();

        let mut deref_live = retained_work;
        let live_kind = (slot.kind == CapKind::Scratch.as_u8())
            | (slot.kind == CapKind::Dispatch.as_u8())
            | (slot.kind == CapKind::ProgramPage.as_u8());
        deref_live.dereference_valid &= slot_in_range & live_kind;
        deref_live.phase = QueuePhase::DerefLowGeneration.as_u8();

        let mut deref_low_generation = retained_work;
        deref_low_generation.dereference_valid &=
            slot.generation as u8 == retained_work.entry.reuse_low8;
        deref_low_generation.phase = QueuePhase::DerefOwner.as_u8();

3. Maintenance reuses the slot and page

After Rift, maintenance runs when tick % 257 equals the relocation-root phase. It sweeps temporary Scratch and Dispatch pages, advances the epoch, and searches slots in the new epoch's digest-derived order. The first eligible old Scratch slot becomes Dispatch.

The host keeps its physical page and owner but changes its kind, rights, and full generation. The first successful pass crosses tick 2217 → 2218. The next windows are 2474 → 2475 and 2731 → 2732.

RUST crates/gate/src/abyss/capability.rs · source lines 1590–1597
                    select_rotation_tombstone_cell(ordered, tombstones[slot_index], gather_mask);
            }
            let eligible = ordered.present
                & (ordered.value.old_kind == CapKind::Scratch.as_u8())
                & !selected.present;
            let latch_mask = std::hint::black_box(RotationSelectionMask::new(eligible));
            selected = select_rotation_tombstone_cell(selected, ordered, latch_mask);
        }

In pass one, generation 0x00000001 becomes 0x00000101, or 1 → 257. Both end in byte 0x01, and passes two and three repeat 257 → 513 and 513 → 769. The slot and physical page stay the same, but the resource identity changes. That means a normal full-generation check rejects the old Scratch handle.

4. The queue checks too little, then the host records exactly what happened

On resume, Gate checks that the slot is live and that its low generation byte equals reuse_low8. It also checks owner, lineage, session, range, and physical page ownership. Those fences still matter, but Gate uses the saved old Scratch kind and write rights.

The queue policy does not require the current full generation or kind to match. So the four bytes reach offset 72 of new Dispatch, and its first nine cells occupy bytes 0..71.

RUST crates/gate/src/abyss/capability.rs · source lines 2550–2567
        let mut deref_address = retained_work;
        let page_valid = slot_in_range & page_in_range & (page_owner == retained_work.entry.slot);
        let cached_authority = (retained_work.entry.cached_kind == CapKind::Scratch.as_u8())
            & (retained_work.entry.cached_rights & RIGHT_WRITE != 0)
            & retained_work.origin.full_validation
            & (retained_work.origin.old_kind == CapKind::Scratch.as_u8())
            & (retained_work.origin.old_rights & RIGHT_WRITE != 0);
        #[cfg(any(test, feature = "abyss-repaired-uaf"))]
        let repair_required = matches!(policy, QueueResumePolicy::FullGenerationAndKind);
        #[cfg(not(any(test, feature = "abyss-repaired-uaf")))]
        let repair_required = {
            let _ = policy;
            false
        };
        let repaired_fields = (slot.generation == retained_work.origin.old_generation)
            & (slot.kind == retained_work.origin.old_kind);
        let policy_valid = !repair_required | repaired_fields;
        deref_address.dereference_valid &= page_valid & cached_authority & policy_valid;

Gate does not mistake this for an ordinary same-object write. It checks the complete old/new story: old Scratch, new Dispatch, different full generations, and equal low bytes. It also checks the same page and owner, plus a pending copy from before the epoch change. Only then does it save StaleExecCap and reuse evidence.

The new capability records the new full generation. This is why reusing the old handle directly fails, while the new host-issued stale record can pass its own check.

RUST crates/gate/src/abyss/capability.rs · source lines 2774–2795
        let stale_conjunction = write_valid
            & origin.full_validation
            & (origin.old_kind == CapKind::Scratch.as_u8())
            & (entry.cached_kind == CapKind::Scratch.as_u8())
            & (entry.cached_rights & RIGHT_WRITE != 0)
            & (slot.kind == CapKind::Dispatch.as_u8())
            & (origin.old_generation != slot.generation)
            & (origin.old_generation as u8 == slot.generation as u8)
            & (origin.old_physical_page == slot.physical_page)
            & (session_difference(origin.session, active_session) == 0)
            & (origin.enqueue_epoch == work.copied_epoch)
            & (work.copied_epoch < self.epoch)
            & (origin.old_generation >> 8 == origin.enqueue_epoch)
            & (slot.generation >> 8 == self.epoch);
        let candidate_cap = StaleExecCap {
            slot: entry.slot,
            full_generation: slot.generation,
            lineage: slot.lineage,
            session_slot: slot.owner_session_slot,
            physical_page: slot.physical_page,
            rights: STALE_EXEC_RIGHTS,
            issued_epoch: self.epoch,

5. Make the stale page prove useful work

The stale record allows a limited upload and execution from that Dispatch page. The bootstrap is a nine-cell inner program. It loads the controlled word at offset 72, the word the bot wrote. It also reads a closed root-view word with the parent proof data. It then combines them, yields, resumes, and compares.

Adjacent SEAL and DRIVE_COMMIT cells request [0,0,0]. The zero request is deliberate: this pass earns proof authority; it is not yet asking for a game boost. The reference trace uses 60 semantic steps across two calls.

RUST crates/gate/src/abyss/threaded.rs · source lines 3494–3513
    let base = page_base(physical_page)?;
    let mut page = [0u8; PAGE_BYTES];
    let controlled_word = u32::try_from(base.checked_add(72)?).ok()?;
    let lit_r7 = make_spec(7, 0, CellWidth::U32, 0);
    let push_r7 = make_spec(0, 7, CellWidth::U8, 0);
    let ld32_r0 = make_spec(0, 0, CellWidth::U32, 0);
    let capld_root_r1 = make_spec(1, 0, CellWidth::U32, ROOT_VIEW_SLOT);
    let xor_r0_r1 = make_spec(0, 1, CellWidth::U32, 2);
    let cmp_r0_r0 = make_spec(0, 0, CellWidth::U32, 0);
    let cells = [
        (Primitive::Lit, lit_r7, 0),
        (Primitive::CursorPush, push_r7, 0),
        (Primitive::Ld32, ld32_r0, controlled_word),
        (Primitive::CapLd, capld_root_r1, 8),
        (Primitive::Alu, xor_r0_r1, 0),
        (Primitive::Yield, 0, 0),
        (Primitive::Cmp, cmp_r0_r0, 0),
        (Primitive::Seal, 0, 0),
        (Primitive::DriveCommit, 0, 0),
    ];

First, the host checks for a current, owned stale record that points to Dispatch. That selects StaleBootstrap, not the normal ProgramPage check. It also requires the saved CalibratedProof, the exact queue write (offset 72, length 4, preflight 62), and the zero request. A matching result alone does not pass.

RUST crates/gate/src/abyss/state.rs · source lines 1959–1973
        let (verifier_kind, target_kind, rights) = match source {
            ExecutionSource::StaleDispatch if slot.kind == CapKind::Dispatch => (
                VerifierKind::StaleBootstrap,
                TargetKind::StaleExec,
                TargetRights {
                    load: false,
                    store: true,
                    execute: true,
                },
            ),
            ExecutionSource::ProgramPage if slot.kind == CapKind::ProgramPage => (
                program_verifier,
                TargetKind::ProgramPage,
                TargetRights::load_store_execute(),
            ),
RUST crates/gate/src/abyss/state.rs · source lines 2072–2083
                let (offset, length, controlled_word, preflight_count) = reuse.controlled_write();
                let semantic_admitted = (offset, length, preflight_count) == (72, 4, 62)
                    && raw == [0; 3]
                    && saturated == [0; 3];
                let evidence = StaleDispatchCertificateEvidence {
                    lineage,
                    session_slot,
                    session_generation: session.generation,
                    calibrated_node_digest: parent.node_digest(),
                    queue_commit: reuse.queue_commit(),
                    bootstrap_program_digest: program_digest,
                    controlled_word,

After a valid seal and commit, the host saves StaleDispatchProof. Its five certificate kinds can support level 12, and the host promotes the page to a durable ProgramPage. The bot repeats the chain for the other two sessions. Once all three have that proof, it uploads 152 words to the last session's page.

Each of those 76 cells is two 32-bit words. The later Chasm pulse costs 240 semantic steps, and an accepted DRIVE request can install [12,12,12]. The recorded hidden medians are 109,658 EXP for Chasm and 69,071 for Rift. These seven-run measurements do not guarantee future scores.

RUST crates/gate/src/abyss/state.rs · source lines 2805–2822
        let stale_node = match staged_provenance.node(stale_id) {
            Some(node)
                if node.kind() == NodeKind::StaleDispatchProof
                    && node.scope() == Scope::Lineage(lineage)
                    && node.certificate_set() == 0x1f
                    && effective_ceiling(node.kind(), node.certificate_set()) == Some(12) =>
            {
                node
            }
            _ => return false,
        };
        std::hint::black_box(stale_node.node_digest());

        let mut staged_capabilities = self.capabilities.clone();
        let capability_session =
            SessionIdentity::new(candidate.identity.session_slot, lineage.as_u8());
        let promotion = match staged_capabilities.promote_stale(handle, capability_session) {
            Some(promotion)

The bot must refresh the boost. Its live loop sends a two-command RUN/DRIVE call for the last session. It also refreshes the earlier Rift power every seven world ticks. That refresh happens on its own prior tick, which keeps the Chasm call the first execution-bearing Gate call on its tick.

Put both on one tick and the comparison retry fails quietly. The snippet builds the Chasm request. It uses generation 0x301 (769) and level bytes 0C 0C 0C, 12 for each track:

PYTHON bots/exploit/abyss_chasm/generate.py · source lines 3347–3367
    emitter.set(V_LINEAGE, 2)
    emitter.set(V_SESSION, 2)
    emit_call_pop(emitter, "stage_load_sequence")
    emit_prepare_command(
        emitter,
        command_offset=0,
        opcode=7,
        target_from=V_TARGET,
        generation=0x301,
    )
    emit_prepare_command(
        emitter,
        command_offset=32,
        opcode=8,
        sequence_delta=1,
        args=(0x000C_0C0C, 0, 0, 0),
    )
    emit_invoke_current(emitter, 2)
    emit_increment(emitter, V_SEQUENCE)
    emit_call_pop(emitter, "stage_store_sequence")
    emit_call_pop(emitter, "stage_tick")

Four ways the Chasm attempt can almost work

The repository packages four control bots, and each changes one part of the working Chasm plan. They all keep their valid Rift work and settle at [8,8,8], but none earns Chasm level 12. A normal-looking call status is not enough. Check the saved proof and the page's identity.

Same Rift starting point. Four different places to miss Chasm.
01 · PROGRAM BYTES

Change the first ProgramPage word

The queued-write route may be set up, but the later program no longer matches the checked page witness. The Chasm pulse does not complete.

Look for:Page identity and timing may be right; the program check is not.
02 · PAUSE TIMING

Use n=61, not 62

The write really succeeds—at step 255. That is before maintenance, so it edits the old Scratch page. It never crosses into Dispatch.

Look for:A successful write with no old-Scratch → new-Dispatch change.
03 · PAGE PLACEMENT

Predict the wrong epoch

The control plans for epoch two while the host rebuilds epoch one. In its frozen first pass, the bot's slot order begins at 15; the real rebuild selects 21.

Look for:The page you prepared is not the page maintenance reused.
04 · CALL ORDER

Send one extra no-op first

A real, accepted 32-byte opcode-0 call runs just before ENQUEUE. That makes ENQUEUE the second Gate call this tick, so no queue entry is inserted.

Look for:No pending entry to carry over the tick boundary.

All four can produce ordinary-looking status and cost observations. The tests check the missing Chasm proof, including that the extra no-op was genuinely accepted.

To debug an attempt, keep a short ledger. At each step, record the tick, session, slot, full generation, current kind, and physical page. At the pause, also note whether Gate copied the queue entry. Record whether ENQUEUE was the first Gate call that tick. If generation and kind never change, the write reached Scratch; if they change without a copied entry, nothing can resume. The Gate checks and proof rules explain the later decisions.

Surface 4: a running module is not an audited module

The goal is a saved ModuleProof, not a bot return value. Chasm leaves three durable program pages and level-12 power. Now Apex must make each page run a longer inner program. That run must pass the host's work check and earn ModuleAudit, the sixth certificate kind. Only then can level 16 pass. First, examine that program.

What the 30-cell program actually does

A cell is one eight-byte Gate instruction: an operation, register settings, and an immediate value. The bot uploads 30 cells, or 60 32-bit words, to each durable page. They are not 30 separate tasks; together they form a setup, a loop, and an ending:

The 30 cells on the page—and the route through them

Each square is one eight-byte instruction. The numbers show where the cells are stored. The track at the bottom shows the order they run.

0–9 · set upStart the challenge, copy its reading cursor, load three answer carriers, then call the loop.
00BEGIN01cursor02copy03copy04copy05LIT06LIT07LIT08ROOT09CALL
10–19 · return pathMix in four words from the earlier proof, seal the work, and request power.
10parent11XOR12parent13XOR14parent15XOR16parent17XOR18SEAL19COMMIT
20–29 · loop bodyRead all 16 challenge words; cell 28 repeats the loop, then cell 29 returns.
20PULSE21add22add23rotate24XOR25rotate26next27compare28JUMP29RET
Run 0–9set up20–28 × 16then RET at 29Run 10–19seal and request

Gold cells 5–7 carry six audit answers: each 32-bit LIT value holds two 16-bit halves. They look like ordinary setup instructions, but the host reads those exact cells during the audit.

The stored cell numbers look out of order because CALL at cell 9 jumps to cell 20. After 16 passes through cells 20–28, RET at cell 29 returns to cell 10. This is a normal function call, just written in Gate's instruction language. The page builder shows these entry and loop cells:

RUST crates/gate/src/abyss/threaded.rs · source lines 3708–3719
    let prefix = [
        (0, Primitive::BeginChallenge, 0, 0),
        (1, Primitive::CursorPush, push_r7, 0),
        (2, Primitive::CursorDup, duplicate_top, 0),
        (3, Primitive::CursorDup, duplicate_top, 0),
        (4, Primitive::CursorDup, duplicate_top, 0),
        (5, Primitive::Lit, lit_r1, carriers[0]),
        (6, Primitive::Lit, lit_r2, carriers[1]),
        (7, Primitive::Lit, lit_r6, carriers[2]),
        (8, Primitive::CapLd, root_depth_two_r3, 12),
        (9, Primitive::Call, 0, loop_target),
    ];
RUST crates/gate/src/abyss/threaded.rs · source lines 3751–3761
    let body = [
        (20, Primitive::CapLd, pulse_post_r6, 0),
        (21, Primitive::Alu, add_r0_r6, 0),
        (22, Primitive::Alu, add_immediate_r0, 0x9e37_79b9),
        (23, Primitive::Alu, rol_immediate_r0, 5),
        (24, Primitive::Alu, xor_r0_r3, 0),
        (25, Primitive::Alu, rol_immediate_r3, 1),
        (26, Primitive::Alu, add_immediate_r4, 4),
        (27, Primitive::Cmp, cmp_ne_immediate_r4, 64),
        (28, Primitive::Cjmp, 0, loop_target),
        (29, Primitive::Ret, 0, 0),

The loop does real input work, starting from the run's nonce. On each pass it adds one challenge word and 0x9e3779b9. Then it rotates the result left by five bits and XORs a rotated root word. It then advances to the next challenge word, and after all 16 words it XORs four parent-proof words. The host calculates the expected answer independently:

RUST crates/gate/src/abyss/full.rs · source lines 395–409
fn replay_visible(view: &ClosedViews, nonce: u32) -> u32 {
    let mut h = nonce;
    let mut root_roll = word(&view.root, 3);
    for index in 0..16 {
        h = h
            .wrapping_add(word(&view.pulse, index))
            .wrapping_add(0x9e37_79b9)
            .rotate_left(5);
        h ^= root_roll;
        root_roll = root_roll.rotate_left(1);
    }
    for index in 0..4 {
        h ^= word(&view.parent, index);
    }
    h

That answer is only one check. A copied final value would skip the loop, but the host's step record exposes the shortcut. The real program performs 505 counted steps, and the first RUN/DRIVE call uses 256 and pauses partway through loop nine.

The second call resumes on the same world tick and uses 249. Seven accounting pad steps fill its 256-step allowance. For debugging, the splits are 46 + 8×24 + 18 = 256 and 6 + 7×24 + 75 = 249.

The six numbers hiding in three ordinary instructions

Cells 5, 6, and 7 are LIT instructions, and each loads a 32-bit value into a register. The host also splits those values into six 16-bit audit answers, m[0] through m[5]. It reads the low half first, then the high half. The host reads these exact setup cells, so identical values elsewhere do not count.

RUST crates/gate/src/abyss/full.rs · source lines 88–98
    let carriers = [cells[5].immediate, cells[6].immediate, cells[7].immediate];
    Some(FullPrefix {
        m: [
            carriers[0] as u16,
            (carriers[0] >> 16) as u16,
            carriers[1] as u16,
            (carriers[1] >> 16) as u16,
            carriers[2] as u16,
            (carriers[2] >> 16) as u16,
        ],
    })

Where do the right six answers come from? On the construction tick, the host derives 16 challenge words. It also derives six starting audit values from the run owner, proof track, session slot and generation, and capability-table epoch. The bot repeats those public calculations for the expected tick. A wrong tick or session generation solves the wrong puzzle.

The host turns the first 13 challenge words into a 6×6 mixing table B and a six-value bias. The last three challenge words give six target halves. Each lane is computed with 16-bit wraparound:

The example makes the formula concrete. Lane 0 starts at 10, and its six weights are all 1. Halves 1,2,3,4,5,6 then give 10+1+2+3+4+5+6=31, exactly its target. Now select Change one half.

Lane 0 becomes 32, while the other lanes also move by their own weights. So one wrong half can fail all six checks.

Six locks, three carrier cells

This example uses made-up inputs with the real audit formula. It does not use a saved game seed. Its first 13 challenge words are zero; the last three encode the six gold targets. Tap the second button to change just the low half of cell 5 by one.

Show the exact teaching inputs

Starting lanes: [10,20,30,40,50,60]. Challenge words 0–12: 0. Challenge words 13–15: 0x003B001F, 0x00660053, 0x007F0073. The host turns those last three words into target lanes [31,59,83,102,115,127].

Rewind a tiny version before solving six lanes

The bot does not have to guess six half-words; it can reverse the calculation instead. Try a two-number example first. Suppose the targets, after removing the known start values and bias, are [18, 56].

The host made them from [m0, m1] in three steps. It added m1 to m0 and multiplied the slots by 3 and 5. Then it added twice the first slot to the second. These are teaching numbers, not a run challenge.

Find two audit answers in an exampleFind two audit answers in an exampleTHE REAL APEX CHECK HAS SIX ANSWERS; THESE SMALL NUMBERS SHOW THE METHODSTARTWanted result after subtracting known start and bias[18, 56]UNDO LOWERSecond slot: 56 − 2×18 = 20[18, 20]UNDO SCALEUndo ×3 and ×5; both factors are odd[6, 4]UNDO UPPERFirst slot: 6 − 4 = 2[2, 4]Check forward: [2, 4] → [6, 4] → [18, 20] → [18, 56].
Rewind a two-answer version of the Apex audit. Start with the wanted pair after removing the known start and bias. Undo the lower mix, undo the odd multipliers, then undo the upper mix. The result is the pair to put in the setup cells. The real audit has six answers and one extra shuffle. View diagram at full size

Read the arrows downward. Undo the last addition: 56 − 2×18 = 20, giving [18, 20]. Undo the multipliers: 18 ÷ 3 = 6 and 20 ÷ 5 = 4, giving [6, 4]. Undo the first addition: 6 − 4 = 2, so the answers are [2, 4]. Check them forward: [2, 4] → [6, 4] → [18, 20] → [18, 56].

The real puzzle has six answers instead of two, and the host also shuffles their positions before mixing them. That sounds worse than it is, because every stage can be undone, one at a time. The code names the mixer L × D × U × P. The next figure shows the host's forward order and the bot's reverse order.

Six answers go forward through the mixer. The bot walks backward.

HOST BUILDS THE TARGET · READ LEFT TO RIGHT

STARTsix answers m[0..5]P · shuffleMove the six slots into a challenge-chosen order.U · upper mixAdd amounts from later slots.D · odd scaleMultiply each slot by an odd 16-bit number.L · lower mixAdd amounts from earlier slots.FINISHadd known start + bias; compare with target

BOT SOLVES THE ANSWERS · FOLLOW THE NUMBERS

  1. Take the target
  2. Subtract start + bias
  3. Undo L
  4. Undo D
  5. Undo U
  6. Undo P to get m[0..5]

The bot reverses a shuffle by putting each value back in its old slot. It reverses the two mixing stages by subtracting values it has already recovered. For L, it works from the first slot toward the last; for U, from the last toward the first. That leaves the multiplication stage.

These 16-bit values wrap to zero after 65,535, so ordinary division cannot undo multiplication. Instead, the bot multiplies by an inverse value for each odd scale. For example, 3 × 43,691 = 131,073, which wraps to 1 at 65,536.

As a result, 18 × 43,691 wraps to 6, which undoes 6 × 3 = 18. The code calls 43,691 a modular inverse of 3, and every odd 16-bit scale has one.

An even scale can erase a clue. For example, both 0 × 2 and 32,768 × 2 wrap to zero. That zero cannot tell you which input was used. This is why the host picks odd scales. The bot can then reverse six small steps instead of guessing among 2^96 six-number answers.

RUST crates/gate/src/abyss/full.rs · source lines 224–247
pub(super) fn replay_audit(
    challenge: [u32; 16],
    r_init: [u16; AUDIT_LANES],
    m: [u16; AUDIT_LANES],
) -> AuditResult {
    let matrix = matrix(challenge);
    let bias = bias(challenge);
    let target = target(challenge);
    let r_final = std::array::from_fn(|row| {
        let mut value = r_init[row].wrapping_add(bias[row]);
        for (column, witness) in m.into_iter().enumerate() {
            value = value.wrapping_add(matrix[row][column].wrapping_mul(witness));
        }
        value
    });
    AuditResult {
        challenge,
        challenge_digest: challenge_digest(challenge),
        r_init,
        m,
        r_final,
        target,
        holds: fixed_u16s_equal(r_final, target),
    }
RUST crates/gate/src/abyss/full.rs · source lines 260–288
pub(super) fn solve_audit_witness(
    challenge: [u32; 16],
    r_init: [u16; AUDIT_LANES],
) -> [u16; AUDIT_LANES] {
    let (lower, diagonal, upper, permutation) = audit_factors(challenge);
    let bias = bias(challenge);
    let target = target(challenge);
    let rhs: [u16; AUDIT_LANES] =
        std::array::from_fn(|i| target[i].wrapping_sub(r_init[i]).wrapping_sub(bias[i]));

    let mut after_lower = [0u16; AUDIT_LANES];
    for i in 0..AUDIT_LANES {
        let mut value = rhs[i];
        for j in 0..i {
            value = value.wrapping_sub(lower[i][j].wrapping_mul(after_lower[j]));
        }
        after_lower[i] = value;
    }
    let after_diagonal: [u16; AUDIT_LANES] =
        std::array::from_fn(|i| after_lower[i].wrapping_mul(inverse_odd(diagonal[i])));
    let mut after_upper = [0u16; AUDIT_LANES];
    for i in (0..AUDIT_LANES).rev() {
        let mut value = after_diagonal[i];
        for j in i + 1..AUDIT_LANES {
            value = value.wrapping_sub(upper[i][j].wrapping_mul(after_upper[j]));
        }
        after_upper[i] = value;
    }
    std::array::from_fn(|column| after_upper[permutation[column]])

The bot calculates its six answers before the construction run. It packs the pairs into the 32-bit values of cells 5–7. The generator computes challenge blocks and starting lanes, then runs its solver. Three write commands patch page offsets 5×8+4, 6×8+4, and 7×8+4:

PYTHON bots/exploit/abyss_chasm/generate.py · source lines 2463–2468
    emitter.set(S5_BLOCK, 0)
    emit_call_pop(emitter, "stage5_challenge_block")
    emitter.set(S5_BLOCK, 1)
    emit_call_pop(emitter, "stage5_challenge_block")
    emit_call_pop(emitter, "stage5_audit_initial")
    emit_call_pop(emitter, "stage5_solve")
PYTHON bots/exploit/abyss_chasm/generate.py · source lines 2479–2492
        emitter.set(V_ARG0, (5 + pair) * 8 + 4)
        emitter.set(S5_ROW, 2 * pair)
        emit_indexed_load(emitter, S5_M, S5_ROW)
        emitter.store(V_ARG1)
        emit_increment(emitter, S5_ROW)
        emit_indexed_load(emitter, S5_M, S5_ROW)
        emitter.push(16)
        emitter.op(OP_SHL)
        emitter.load(V_ARG1)
        emitter.op(OP_OR)
        emitter.store(V_ARG1)
        emitter.set(V_ARG2, 0)
        emitter.set(V_ARG3, 0)
        emit_call_pop(emitter, "stage_cmd_write")

Why a correct final number can still stop at level 12

Gate runs the page once with the real data, then six more times with changed data. Each comparison run removes one proof source, and the host asks: did that source change the result? The first five removals must change the seal. The sixth removes ModuleAudit, which exists only if all six audit answers match their targets.

Same 505-step program shape. Two possible proof results.

Both routes: The page runs the full trace and matches the expected result. Removing any of the first five sources changes its seal.

Five sources

Audit answers miss

1 At least one of the six final audit values misses its target.

2 There is no ModuleAudit source. Removing it in replay changes nothing: same seal.

3 Host saves a base ModuleProof with set 0x1F.

Highest possible level12
Six sources

Audit answers hit

1 All six final audit values equal their targets.

2 ModuleAudit exists. Removing it in replay changes the seal: different seal.

3 Host saves a complete ModuleProof with set 0x3F.

Highest possible level16

A correct program result alone is not enough for 16. The host needs proof that its sixth source made a difference.

This is why deliberately wrong m values can still produce a level-12 base Module. The host does not reject the whole program. In the sixth comparison, “seal changed” must match “audit passed”:

RUST crates/gate/src/abyss/full.rs · source lines 826–850
        let terminals_match = submission.actual_r0 == self.expected_r0[0]
            && fixed_u32s_equal(submission.counterfactual_r0, counterfactual);
        let mut absorption = true;
        for lane in 0..5 {
            absorption &= submission.actual_r0 != submission.counterfactual_r0[lane];
        }
        absorption &= (submission.actual_r0 != submission.counterfactual_r0[5]) == self.audit_holds;
        let valid = submission.verifier == VerifierKind::FullProgram
            && fixed_identity_equal(submission.identity, self.identity)
            && fixed_bytes_equal(submission.program_digest, self.program_digest)
            && submission.semantic_steps_before_pair == FULL_PRE_TERMINAL_STEPS
            && submission.audit_capture_count == 3
            && fixed_u16s_equal(submission.audit_capture, self.m)
            && submission.actual_condition == false
            && submission.counterfactual_condition == [false; COUNTERFACTUAL_LANES]
            && terminals_match
            && absorption
            && full_coverage_valid(submission.coverage)
            && full_trace_valid(
                submission.trace,
                self.views,
                self.identity.ingress_nonce,
                self.m,
            )
            && submission.has_full_terminal_sources();

The saved node follows the same split, and the host checks its exact certificate set and level limit before commit:

RUST crates/gate/src/abyss/state.rs · source lines 3108–3123
        let expected_set = if audit.holds {
            MODULE_COMPLETE_CERTIFICATE_SET
        } else {
            MODULE_BASE_CERTIFICATE_SET
        };
        let module = match staged_provenance.node(module_id) {
            Some(node)
                if node.kind() == NodeKind::ModuleProof
                    && node.scope() == Scope::Lineage(lineage)
                    && node.certificate_set() == expected_set
                    && effective_ceiling(node.kind(), node.certificate_set())
                        == Some(if audit.holds { 16 } else { 12 }) =>
            {
                *node
            }
            _ => return false,

How the Apex bot keeps the result alive

For each track, the generator uploads a 30-cell program to that track's durable page. It adjusts the jump targets for each page address. It predicts the construction tick, solves that tick's six-lane puzzle, and patches the three carrier cells. Then it makes two adjacent RUN/DRIVE calls in one frozen tick. This code assembles the paired calls:

PYTHON bots/exploit/abyss_chasm/generate.py · source lines 2506–2528
    for _ in range(2):
        emitter.copy(V_GENERATION, V_VIEW2)
        emit_call_pop(emitter, "stage_load_sequence")
        emit_prepare_command(
            emitter,
            command_offset=0,
            opcode=7,
            target_from=V_TARGET,
            generation_from=V_GENERATION,
        )
        emit_prepare_command(
            emitter,
            command_offset=32,
            opcode=8,
            sequence_delta=1,
            args=(APEX_REQUEST_WORD, 0, 0, 0),
        )
        emit_invoke_current(emitter, 2)
        emit_increment(emitter, V_SEQUENCE)
        emit_call_pop(emitter, "stage_store_sequence")
    emit_increment(emitter, V_LINEAGE)
    emit_call_pop(emitter, "stage_tick")

After all three Module proofs exist, the bot keeps requesting [16,16,16]. It revisits each track every three world ticks with paired calls, and the Drive effect lasts eight ticks. The saved program and carrier tuple stay tied to that track.

The host checks the tuple again on later work. A different page with changed carrier bits cannot replace it silently. The Apex artifact reports a 336,338-EXP hidden median across seven runs. This is a recorded result, not a promise for every seed. The bot comparison shows the score range and the pilot's effect.

The full route is upload page → solve and patch three cells → run 256 + 249 steps → pass trace and audit → save Module proof → refresh three boosts. If power stays at 12, check the construction tick and six carrier values. Also check the step split and sixth comparison before you change the combat pilot.

Surface 5: verified work is still not unlimited power

Proof and applied power are different. A certificate is the host's receipt for one accepted check. A proof node links such receipts to earlier accepted work. A Drive setting is the short boost the knight can use. A guest value that merely looks like a receipt cannot replace any of these host records.

From inner result to usable boost

Candidate resultThe inner machine says what happened.
CertificateThe host accepts one check and makes a bound receipt.
Proof nodeThe host links this receipt to earlier accepted work.
DriveA time-limited, capped effect reaches the arena.

EXP is still downstream: fight, drop, pickup. The proof chain changes the knight's tools, not the score counter directly.

Saved proof shows why a result is allowedSaved proof shows why a result is allowedEarlier proof AOne accepted host check,tied to its owner.Earlier proof BAnother accepted check;the bot cannot invent it.Proposed new nodeProof step + owner +earlier links + checkeddata.Saved nodeFixed identity;checked links.verifyTwo limits both apply:the proof step and different certificate kinds.This diagram shows links between accepted results.
Earlier proof and different certificate kinds set the power limit. The proof step has one maximum; the number of valid certificate kinds has another. The lower maximum wins. Only then can the Drive setting apply a requested boost. View diagram at full size
Accepted proof step Maximum level from this step What remains
Entry 4 More different certificate kinds and later proof steps
Calibrated 8 Saved-dispatch and module work
Stale dispatch 12 Module audit for the final stage
Module 16 All six different kinds for level 16

For zero through six certificate kinds, the level limits are 0, 2, 4, 6, 8, 12, 16. The engine uses the lower of this limit and the proof-step limit. For example, a Module node with five kinds stops at 12. A Calibrated node with six kinds stops at 8. The host must still accept and save each node first.

Apply this rule to the 505-step program. A valid trace with wrong audit answers can save a base Module node with five certificate kinds. That node supports at most level 12, so a request for 16 cannot exceed that limit. Correct answers and a passing sixth comparison give six kinds.

Then both limits can reach 16. The node stays saved even when its active Drive boost expires, so the bot must keep refreshing the boost.

The world uses the current Drive effects in combat, survival, and collection, while EXP still comes from collected gems. The ABYSS economy chapter shows the effect calculations. The shared world chapter shows the kill → drop → collection route.

Keep one state ledger as you read the implementation

This ledger shows what can change at each stage. If one stage fails, it cannot create proof from a later stage.

Moment Owned request Fibre / queue Capability identity Evidence arena Drive / world
Before admission No request Existing limited state Current live objects Existing accepted proof Existing temporary effect
After safe ingress Immutable copy Unchanged until admitted Unchanged Unchanged Unchanged
After admission Bound to owner/context Work may be pending Grants checked under policy Unchanged Unchanged
After execution Same copy Progress, stop, or pause Rechecked where needed Proposed result only Unchanged
After host check Same copy Completion recorded Route and owner checked Node prepared Unchanged
After saving proof and Drive Same copy Next work may proceed Current table still decides Accepted node saved Limited temporary boost; game continues

Use this ledger while you read the next chapters. Gate explains execution and resource checks. Evidence explains saved records and limits. Effects shows how power helps the knight earn EXP.