Status: Closed (fix shipped — opening here for the audit trail) Reported by: Lesson 50103 failure ("Register rbx = 0x1122334455667800" displayed as expected, despite the lesson source asserting 0x11223344556677FF). Affected component:RegisterStateAssertion round-trip between SpikerSoft.Business.Domain.Lessons.Curriculum.X86.X86LessonPlan and X86LessonGraderService on the SPA.
Symptom
On lessonId=50103 (bl, bh, bx: writing partial registers):
The lesson instructions, hint 3, and X86ReferenceSolutions.cs all agree that mov rbx, 0x1122334455667700 then mov bl, 0xFF should produce rbx = 0x11223344556677FF and pass.
The student types exactly that, the Blink WASM emulator correctly reports rbx = 0x11223344556677FF, but the grader fails with:
The "expected" the grader displays is the lesson's true expected value plus 1.
Root cause
RegisterStateAssertion.Expected is IReadOnlyDictionary<string, long>. The plan is JSON-serialized in X86LessonStrategyBase.BuildAttempt, stuffed into LessonAttemptState.TestCode, and shipped to the SPA as part of BrowserGradingState. The TS grader's mirror interface declared the field as Record<string, number>:
System.Text.Json writes a C# long as a bare JSON number. The browser's JSON.parse produces a JavaScript number — an IEEE 754 double with only 53 bits of mantissa. Around 2^60, the gap between consecutive representable doubles is 2^(60-52) = 256, so:
Rounds up → the parsed number is 1234605616436508672 = 0x1122334455667800
BigInt(rawExpected) then converts the already-rounded double to a BigInt — fidelity is gone before BigInt ever sees it. The actual register snapshot is sent as a hex string and BigInt-parsed losslessly, so the comparison is 0x11223344556677FFn !== 0x1122334455667800n and the row fails. The doc-comment on RegisterStateAssertion ("the JSON serializer carries them as numbers, the grader reinterprets as BigInt on the SPA side for full 64-bit fidelity") was wishful thinking — BigInt cannot un-lose precision the JSON parse already discarded.
The server-side replay grader (BlinkX86LessonGradingExecutor) keeps everything in long/ulong and never round-trips through a JS number, which is why BlinkX86LessonGradingExecutorTests.cs passed for 0xFFFFFFFFFFFFFFFF. Only the SPA-side grading path was broken, and that's the one production uses for x86 lessons.
Affected lessons
Any register expected value whose magnitude exceeds 2^53 (0x20_0000_0000_0000) and whose low bits aren't conveniently zero. A scan of the x86 curriculum at the time of writing:
50103 — 0x11223344556677FFL — broken.
50202 — (rbx, -6L), (rcx, -5L) — fine (small magnitudes; the negative-fixup compensates).
All other Tiers 1-7 register assertions today — fine (small values).
So 50103 was the only currently-affected lesson, but the bug was latent for any future lesson author who writes a non-trivial 64-bit value.
Fix
Change the wire format for RegisterStateAssertion.Expected from JSON numbers to hex strings (mirroring what the actual register snapshot already does):
Backend — Add HexEncodedLongDictionaryConverter (a JsonConverter<IReadOnlyDictionary<string, long>>) that:
Writes each value as "0x{value:X}" reinterpreted as ulong (so -6L becomes "0xFFFFFFFFFFFFFFFA").
Reads strings (hex with optional 0x prefix) AND raw JSON numbers (back-compat for any older client / cached payload). Hex is parsed as ulong and reinterpreted to long.
Apply via [property: JsonConverter(typeof(HexEncodedLongDictionaryConverter))] on the record positional parameter.
Frontend — Change RegisterStateAssertion.expected type to Readonly<Record<string, string>>. BigInt(rawExpected) already accepts hex literals ("0x...") directly, so the grader gets a lossless bigint. The legacy "treat negative-as-unsigned" fix-up becomes a no-op (the wire is unsigned hex now), but stays as defensive code.
Tests — Add a regression test on each side for 0x11223344556677FFL to ensure the round-trip stays lossless.
Doc — Replace the wishful-thinking sentence on RegisterStateAssertion (X86LessonPlan.cs:80-88) with the actual contract.
Added HexEncodedLongDictionaryConverter (write: unsigned hex with 0x prefix; read: hex string OR raw JSON number for back-compat). Applied via [property: JsonConverter(...)] on RegisterStateAssertion.Expected. Replaced the misleading <summary> doc-comment with the actual contract.
Added three regressions: RegisterStateAssertion_PreservesValuesAbove2to53Losslessly (the bug-for-bug test using 0x11223344556677FF), RegisterStateAssertion_WireFormat_EmitsExpectedAsHexStrings (asserts the on-the-wire JSON shape), RegisterStateAssertion_ReadSide_TolerantOfLegacyJsonNumbers (back-compat for any cached payload from the old serializer).
RegisterStateAssertion.expected retyped from Record<string, number> → Record<string, string>. gradeRegisters parses with BigInt(rawExpected); the unsigned-fixup branch becomes a defensive no-op since the new wire is always unsigned hex. Updated the JSDoc to document the contract.
Existing register tests migrated to the string form. Added preserves values above 2^53 losslessly (regression for lesson 50103) exercising 0x11223344556677ff end-to-end.
Validation
dotnet test --filter "FullyQualifiedName~BlinkX86LessonGradingExecutorTests|FullyQualifiedName~X86CurriculumTests" — 332 passed, 0 failed. Every existing assertion still passes; the three new regressions also pass. The smoke test that round-trips every Challenge's TestCode through JsonSerializer.Deserialize<X86LessonPlan> continues to pass, confirming the converter doesn't break any existing lesson's serialization.
nx test feature-dev-tools-x86-playground — 20 passed, 0 failed, including the new regression case that ships expected: { rbx: "0x11223344556677ff" } and verifies the row passes byte-for-byte.
Dev server (npm run serve:spikersoft-development) rebuilt cleanly with no new warnings; the x86-playground lazy chunk size is unchanged at 419.18 kB.
The read side accepts both shapes, so any in-flight payload from the previous serializer (cached BrowserGradingState in the user's IndexedDB queue, Redis-cached LessonAttempt.TestCode) will still grade correctly after deploy. New writes always emit the hex-string form.
**Status:** Closed (fix shipped — opening here for the audit trail)
**Reported by:** Lesson 50103 failure ("Register rbx = 0x1122334455667800" displayed as expected, despite the lesson source asserting `0x11223344556677FF`).
**Affected component:** `RegisterStateAssertion` round-trip between `SpikerSoft.Business.Domain.Lessons.Curriculum.X86.X86LessonPlan` and `X86LessonGraderService` on the SPA.
## Symptom
On `lessonId=50103` (`bl, bh, bx: writing partial registers`):
- The lesson instructions, hint 3, and `X86ReferenceSolutions.cs` all agree that `mov rbx, 0x1122334455667700` then `mov bl, 0xFF` should produce `rbx = 0x11223344556677FF` and pass.
- The student types exactly that, the Blink WASM emulator correctly reports `rbx = 0x11223344556677FF`, but the grader fails with:
> Register rbx = 0x1122334455667800
> Got 0x11223344556677ff (1234605616436508671).
The "expected" the grader displays is the lesson's true expected value plus 1.
## Root cause
`RegisterStateAssertion.Expected` is `IReadOnlyDictionary<string, long>`. The plan is JSON-serialized in `X86LessonStrategyBase.BuildAttempt`, stuffed into `LessonAttemptState.TestCode`, and shipped to the SPA as part of `BrowserGradingState`. The TS grader's mirror interface declared the field as `Record<string, number>`:
```typescript
export interface RegisterStateAssertion {
readonly $type: "registerState";
readonly expected: Readonly<Record<string, number>>;
}
```
`System.Text.Json` writes a C# `long` as a bare JSON number. The browser's `JSON.parse` produces a JavaScript `number` — an IEEE 754 double with only 53 bits of mantissa. Around 2^60, the gap between consecutive representable doubles is `2^(60-52) = 256`, so:
- `0x11223344556677FF` = `1234605616436508671`
- Nearest representable doubles: `1234605616436508416` (`0x1122334455667700`, distance 255) and `1234605616436508672` (`0x1122334455667800`, distance **1**)
- Rounds **up** → the parsed `number` is `1234605616436508672` = `0x1122334455667800`
`BigInt(rawExpected)` then converts the already-rounded double to a BigInt — fidelity is gone before BigInt ever sees it. The actual register snapshot is sent as a hex string and `BigInt`-parsed losslessly, so the comparison is `0x11223344556677FFn !== 0x1122334455667800n` and the row fails. The doc-comment on `RegisterStateAssertion` ("the JSON serializer carries them as numbers, the grader reinterprets as `BigInt` on the SPA side for full 64-bit fidelity") was wishful thinking — `BigInt` cannot un-lose precision the JSON parse already discarded.
The server-side replay grader (`BlinkX86LessonGradingExecutor`) keeps everything in `long`/`ulong` and never round-trips through a JS number, which is why `BlinkX86LessonGradingExecutorTests.cs` passed for `0xFFFFFFFFFFFFFFFF`. Only the SPA-side grading path was broken, and that's the one production uses for x86 lessons.
## Affected lessons
Any register expected value whose magnitude exceeds 2^53 (`0x20_0000_0000_0000`) and whose low bits aren't conveniently zero. A scan of the x86 curriculum at the time of writing:
- 50103 — `0x11223344556677FFL` — **broken**.
- 50202 — `(rbx, -6L), (rcx, -5L)` — fine (small magnitudes; the negative-fixup compensates).
- All other Tiers 1-7 register assertions today — fine (small values).
So 50103 was the only currently-affected lesson, but the bug was latent for any future lesson author who writes a non-trivial 64-bit value.
## Fix
Change the wire format for `RegisterStateAssertion.Expected` from JSON numbers to hex strings (mirroring what the actual register snapshot already does):
1. **Backend** — Add `HexEncodedLongDictionaryConverter` (a `JsonConverter<IReadOnlyDictionary<string, long>>`) that:
- Writes each value as `"0x{value:X}"` reinterpreted as `ulong` (so `-6L` becomes `"0xFFFFFFFFFFFFFFFA"`).
- Reads strings (hex with optional `0x` prefix) AND raw JSON numbers (back-compat for any older client / cached payload). Hex is parsed as `ulong` and reinterpreted to `long`.
Apply via `[property: JsonConverter(typeof(HexEncodedLongDictionaryConverter))]` on the record positional parameter.
2. **Frontend** — Change `RegisterStateAssertion.expected` type to `Readonly<Record<string, string>>`. `BigInt(rawExpected)` already accepts hex literals (`"0x..."`) directly, so the grader gets a lossless `bigint`. The legacy "treat negative-as-unsigned" fix-up becomes a no-op (the wire is unsigned hex now), but stays as defensive code.
3. **Tests** — Add a regression test on each side for `0x11223344556677FFL` to ensure the round-trip stays lossless.
4. **Doc** — Replace the wishful-thinking sentence on `RegisterStateAssertion` (`X86LessonPlan.cs:80-88`) with the actual contract.
## Resolution
**Closed.** All four items implemented and validated.
### Changes
| File | Change |
|---|---|
| `spikersoft-backend/SpikerSoft.Business/Domain/Lessons/Curriculum/X86/X86LessonPlan.cs` | Added `HexEncodedLongDictionaryConverter` (write: unsigned hex with `0x` prefix; read: hex string OR raw JSON number for back-compat). Applied via `[property: JsonConverter(...)]` on `RegisterStateAssertion.Expected`. Replaced the misleading `<summary>` doc-comment with the actual contract. |
| `spikersoft-backend/SpikerSoft.Tests.Unit/Domain/CodeExecution/Execution/BlinkX86LessonGradingExecutorTests.cs` | Added three regressions: `RegisterStateAssertion_PreservesValuesAbove2to53Losslessly` (the bug-for-bug test using `0x11223344556677FF`), `RegisterStateAssertion_WireFormat_EmitsExpectedAsHexStrings` (asserts the on-the-wire JSON shape), `RegisterStateAssertion_ReadSide_TolerantOfLegacyJsonNumbers` (back-compat for any cached payload from the old serializer). |
| `spikersoft-angular/libraries/features/dev-tools-x86-playground/src/lib/services/x86-lesson-grader.service.ts` | `RegisterStateAssertion.expected` retyped from `Record<string, number>` → `Record<string, string>`. `gradeRegisters` parses with `BigInt(rawExpected)`; the unsigned-fixup branch becomes a defensive no-op since the new wire is always unsigned hex. Updated the JSDoc to document the contract. |
| `spikersoft-angular/libraries/features/dev-tools-x86-playground/src/lib/services/x86-lesson-grader.service.spec.ts` | Existing register tests migrated to the string form. Added `preserves values above 2^53 losslessly (regression for lesson 50103)` exercising `0x11223344556677ff` end-to-end. |
### Validation
- `dotnet test --filter "FullyQualifiedName~BlinkX86LessonGradingExecutorTests|FullyQualifiedName~X86CurriculumTests"` — **332 passed, 0 failed.** Every existing assertion still passes; the three new regressions also pass. The smoke test that round-trips every Challenge's `TestCode` through `JsonSerializer.Deserialize<X86LessonPlan>` continues to pass, confirming the converter doesn't break any existing lesson's serialization.
- `nx test feature-dev-tools-x86-playground` — **20 passed, 0 failed**, including the new regression case that ships `expected: { rbx: "0x11223344556677ff" }` and verifies the row passes byte-for-byte.
- Dev server (`npm run serve:spikersoft-development`) rebuilt cleanly with no new warnings; the x86-playground lazy chunk size is unchanged at `419.18 kB`.
### Wire-format change summary
Before:
```json
{ "$type": "registerState", "expected": { "rbx": 1234605616436508671 } }
```
After:
```json
{ "$type": "registerState", "expected": { "rbx": "0x11223344556677FF" } }
```
The read side accepts both shapes, so any in-flight payload from the previous serializer (cached `BrowserGradingState` in the user's IndexedDB queue, Redis-cached `LessonAttempt.TestCode`) will still grade correctly after deploy. New writes always emit the hex-string form.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Status: Closed (fix shipped — opening here for the audit trail)
Reported by: Lesson 50103 failure ("Register rbx = 0x1122334455667800" displayed as expected, despite the lesson source asserting
0x11223344556677FF).Affected component:
RegisterStateAssertionround-trip betweenSpikerSoft.Business.Domain.Lessons.Curriculum.X86.X86LessonPlanandX86LessonGraderServiceon the SPA.Symptom
On
lessonId=50103(bl, bh, bx: writing partial registers):The lesson instructions, hint 3, and
X86ReferenceSolutions.csall agree thatmov rbx, 0x1122334455667700thenmov bl, 0xFFshould producerbx = 0x11223344556677FFand pass.The student types exactly that, the Blink WASM emulator correctly reports
rbx = 0x11223344556677FF, but the grader fails with:The "expected" the grader displays is the lesson's true expected value plus 1.
Root cause
RegisterStateAssertion.ExpectedisIReadOnlyDictionary<string, long>. The plan is JSON-serialized inX86LessonStrategyBase.BuildAttempt, stuffed intoLessonAttemptState.TestCode, and shipped to the SPA as part ofBrowserGradingState. The TS grader's mirror interface declared the field asRecord<string, number>:System.Text.Jsonwrites a C#longas a bare JSON number. The browser'sJSON.parseproduces a JavaScriptnumber— an IEEE 754 double with only 53 bits of mantissa. Around 2^60, the gap between consecutive representable doubles is2^(60-52) = 256, so:0x11223344556677FF=12346056164365086711234605616436508416(0x1122334455667700, distance 255) and1234605616436508672(0x1122334455667800, distance 1)numberis1234605616436508672=0x1122334455667800BigInt(rawExpected)then converts the already-rounded double to a BigInt — fidelity is gone before BigInt ever sees it. The actual register snapshot is sent as a hex string andBigInt-parsed losslessly, so the comparison is0x11223344556677FFn !== 0x1122334455667800nand the row fails. The doc-comment onRegisterStateAssertion("the JSON serializer carries them as numbers, the grader reinterprets asBigInton the SPA side for full 64-bit fidelity") was wishful thinking —BigIntcannot un-lose precision the JSON parse already discarded.The server-side replay grader (
BlinkX86LessonGradingExecutor) keeps everything inlong/ulongand never round-trips through a JS number, which is whyBlinkX86LessonGradingExecutorTests.cspassed for0xFFFFFFFFFFFFFFFF. Only the SPA-side grading path was broken, and that's the one production uses for x86 lessons.Affected lessons
Any register expected value whose magnitude exceeds 2^53 (
0x20_0000_0000_0000) and whose low bits aren't conveniently zero. A scan of the x86 curriculum at the time of writing:0x11223344556677FFL— broken.(rbx, -6L), (rcx, -5L)— fine (small magnitudes; the negative-fixup compensates).So 50103 was the only currently-affected lesson, but the bug was latent for any future lesson author who writes a non-trivial 64-bit value.
Fix
Change the wire format for
RegisterStateAssertion.Expectedfrom JSON numbers to hex strings (mirroring what the actual register snapshot already does):Backend — Add
HexEncodedLongDictionaryConverter(aJsonConverter<IReadOnlyDictionary<string, long>>) that:"0x{value:X}"reinterpreted asulong(so-6Lbecomes"0xFFFFFFFFFFFFFFFA").0xprefix) AND raw JSON numbers (back-compat for any older client / cached payload). Hex is parsed asulongand reinterpreted tolong.Apply via
[property: JsonConverter(typeof(HexEncodedLongDictionaryConverter))]on the record positional parameter.Frontend — Change
RegisterStateAssertion.expectedtype toReadonly<Record<string, string>>.BigInt(rawExpected)already accepts hex literals ("0x...") directly, so the grader gets a losslessbigint. The legacy "treat negative-as-unsigned" fix-up becomes a no-op (the wire is unsigned hex now), but stays as defensive code.Tests — Add a regression test on each side for
0x11223344556677FFLto ensure the round-trip stays lossless.Doc — Replace the wishful-thinking sentence on
RegisterStateAssertion(X86LessonPlan.cs:80-88) with the actual contract.Resolution
Closed. All four items implemented and validated.
Changes
spikersoft-backend/SpikerSoft.Business/Domain/Lessons/Curriculum/X86/X86LessonPlan.csHexEncodedLongDictionaryConverter(write: unsigned hex with0xprefix; read: hex string OR raw JSON number for back-compat). Applied via[property: JsonConverter(...)]onRegisterStateAssertion.Expected. Replaced the misleading<summary>doc-comment with the actual contract.spikersoft-backend/SpikerSoft.Tests.Unit/Domain/CodeExecution/Execution/BlinkX86LessonGradingExecutorTests.csRegisterStateAssertion_PreservesValuesAbove2to53Losslessly(the bug-for-bug test using0x11223344556677FF),RegisterStateAssertion_WireFormat_EmitsExpectedAsHexStrings(asserts the on-the-wire JSON shape),RegisterStateAssertion_ReadSide_TolerantOfLegacyJsonNumbers(back-compat for any cached payload from the old serializer).spikersoft-angular/libraries/features/dev-tools-x86-playground/src/lib/services/x86-lesson-grader.service.tsRegisterStateAssertion.expectedretyped fromRecord<string, number>→Record<string, string>.gradeRegistersparses withBigInt(rawExpected); the unsigned-fixup branch becomes a defensive no-op since the new wire is always unsigned hex. Updated the JSDoc to document the contract.spikersoft-angular/libraries/features/dev-tools-x86-playground/src/lib/services/x86-lesson-grader.service.spec.tspreserves values above 2^53 losslessly (regression for lesson 50103)exercising0x11223344556677ffend-to-end.Validation
dotnet test --filter "FullyQualifiedName~BlinkX86LessonGradingExecutorTests|FullyQualifiedName~X86CurriculumTests"— 332 passed, 0 failed. Every existing assertion still passes; the three new regressions also pass. The smoke test that round-trips every Challenge'sTestCodethroughJsonSerializer.Deserialize<X86LessonPlan>continues to pass, confirming the converter doesn't break any existing lesson's serialization.nx test feature-dev-tools-x86-playground— 20 passed, 0 failed, including the new regression case that shipsexpected: { rbx: "0x11223344556677ff" }and verifies the row passes byte-for-byte.npm run serve:spikersoft-development) rebuilt cleanly with no new warnings; the x86-playground lazy chunk size is unchanged at419.18 kB.Wire-format change summary
Before:
After:
The read side accepts both shapes, so any in-flight payload from the previous serializer (cached
BrowserGradingStatein the user's IndexedDB queue, Redis-cachedLessonAttempt.TestCode) will still grade correctly after deploy. New writes always emit the hex-string form.