Skip to content

SCORM conformance testing

The offline suites cannot answer whether a real LMS accepts this package or agrees with how it reports learner state. That needs a live run, and it is the last unverified thing about the SCORM path.

Two halves, and only the first can be automated.

1. Package validation — automated

Tests~/tools/validate-package.py checks a generated .zip against what an importer inspects before it will accept the package: manifest well-formedness, namespace declarations, identifier uniqueness, identifierref resolution, scormtype/scormType casing, URI-safe hrefs, declared-versus-present file parity, that no schemaLocation names a file the package omits, the launch file, and the injected page helper.

python Tests~/tools/validate-package.py Builds/scorm12.zip Builds/scorm2004.zip

CI runs this on every push, against packages the EditMode suite generates through the shipped packaging code (ScormBuildSmokeTests), and uploads them as the scorm-packages artifact. That is deliberate: the figure quoted here used to come from a local Builds/ folder that is not in the repository, so nobody could reproduce it from a clone — and with no arguments the script printed "0/0 checks passed" and exited 0, which in CI is indistinguishable from a clean run. It now exits non-zero when it validates nothing.

This covers the checks an importer applies before it will accept a package, which is where most import failures come from. It is not schema validation and not the ADL Test Suite: it says nothing about runtime behaviour, and it does not validate the manifest against the official control documents.

Schema validation — checked by hand, 2026-10-01

Done once, directly, rather than left as an assumption. Both manifests were taken from packages ScormBuildSmokeTests generated through the shipped packaging code, and validated against the official control documents:

Manifest Validated against Result
SCORM 1.2 adlcp_rootv1p2.xsd, which imports imscp_rootv1p1p2.xsd valid
SCORM 2004 imscp_v1p1.xsd valid

Load the IMS content-packaging schema alone and the 1.2 manifest reports one error — adlcp:scormtype unresolvable against a strict wildcard. That is the ADL extension schema being absent from the validator, not a defect in the manifest; entering through adlcp_rootv1p2.xsd, which imports the IMS schema, resolves it and the manifest validates. Worth knowing before repeating this, because the failure looks like a real one.

Why this is not automated. Running it in CI means either shipping the control documents or fetching them, and neither is clean. The ADL files are US Government work, but the IMS ones are 1EdTech copyright whose notice grants excerpts for requests for proposals and refers product use to a separate licence — not a redistribution grant a commercial package should assume. Fetching instead does not work either: imsglobal.org serves its schema, but the ADL namespace URLs return 500 or nothing, so the step would fail for reasons unconnected to the code, which is the kind of check people learn to ignore.

The value lost is small. The manifest is generated from a fixed template with a handful of substitutions, so the failures schema validation would catch are the ones ScormManifestTests and the validator above already cover. This entry exists so the result is on record rather than repeated.

2. Runtime conformance — manual

Needs a SCORM Cloud account (the free tier is enough) and a browser. Upload each package, launch it, drive the validation panel, then read the registration's Debug Log — that is where the actual LMSSetValue / SetValue traffic and any error codes appear.

Test both packages. SCORM 1.2 and 2004 use different API objects, different element names and different data models; a pass on one says nothing about the other.

Use a platform other than the one any previous validation ran on, so the result is evidence about the product rather than a repeat of a known-good pairing.

Editor coverage

The packages under test can come from either of the first two editors, both of which have been built and packaged end to end. The build, package and validator columns were measured on 2026-10-02; the suite figures were re-measured on 2026-10-06, after the post-1.0 fixes, the numeric-read fix and the single-flight fix took the count to 377:

Editor Suite WebGL build Package Validator Held by CI
6000.3.5f2 377/377 yes yes 23/23 per package yes, parked 2026-10-18
2021.3.45f2 377/377 yes yes 23/23 no
6000.0.84f1 377/377 not measured yes 23/23 per package no
6000.3.26f1 377/377 not measured yes 23/23 per package no

Only the 6000.3 row is a claim. It is the declared floor in package.json, and until 2026-10-18 every commit re-established it.

From 2026-10-18 Unity CI is parked. The self-hosted runner holding the activated editor is in storage until around mid-December; the reasoning is in the header of .github/workflows/tests.yml. For that window the claim's backing has to be named rather than assumed:

  • CI last re-established it on 2026-10-07, run 37603336075 against commit d9c4796 — the most recent commit whose EditMode job actually executed.
  • Every commit since has touched documentation or the workflows only. No runtime code has changed, which is why the claim stands rather than being withdrawn.
  • The interim evidence is two hand runs, both in the table above. The stronger is 6000.3.26f1, which satisfies the 6000.3 floor package.json declares — but it is not the pinned 6000.3.5f2, so it reproduces the stream CI holds rather than the exact patch. The other is 6000.0.84f1, below the floor entirely. Neither is automation, and nothing re-establishes either.

So for the parked window the 6000.3 claim rests on three things, none of them automation: the release history to 2026-10-07, a frozen API with no runtime change since, and a hand run on the declared floor. The first runtime change that lands while CI is parked expires the second of those, and this paragraph has to be revisited rather than left standing. This file has withdrawn a conformance claim before, when the evidence stopped holding (880d179), and that is the standard here: nothing implies coverage it does not have.

The 2021.3 row is a measurement, taken by hand on 2026-10-02, and nothing re-establishes it: Unity licence activation fails on 2021.3 in CI with the same credentials that activate on 6000.3, so there is no leg to hold it. The README says this plainly rather than implying support the automation does not defend.

The 6000.0.84f1 row is below the declared floor and is not a support statement. package.json declares 6000.3 and the store listing says the same. The row records only that the suite and the packaging path also pass on the earlier Unity 6 feature stream, measured on 2026-10-17 against a junctioned working tree. Its Package and Validator figures come from ScormBuildSmokeTests and validate-package.py — the same automated path CI uses — while no WebGL player was built by hand on it, which is why that column is not a yes. It also ran against test framework 1.6.0, which that editor bundles as mustBeBundled and which rejects the 1.5.1 the CI workflow pins, so the count is stable across two test-framework versions as well. Like 2021.3, nothing re-establishes it.

The 6000.3.26f1 row is on the declared floor, and is the better of the two hand runs. Measured on 2026-10-18 against the same junctioned working tree, on a Hub-installed editor with WebGL build support: 377/377, and 23/23 per package from ScormBuildSmokeTests and validate-package.py. It is the same 6000.3 minor stream the package declares and CI pins, which is why it carries more weight than the 6000.0 row for the parked window. It is still not 6000.3.5f2 — a patch release apart, measured once by hand, with nothing to re-establish it. The WebGL column is not a yes for the same reason as the row above: the active build target was set to WebGL for the run, but no player was built.

Worth recording that all three editors agree exactly, including on the reflection-derived public-member baseline — a surface snapshot taken by reflection could in principle differ between runtimes, and does not.

The live runs below were executed against 6000.3.5f2 packages. SCORM Cloud has not been driven against 2021.3 or 6000.0 output.

Sample scenes — checked 2026-10-02

Both sample scenes open in a Built-in render pipeline project on 6000.3.5f2 with nothing on the console:

Scene Root objects Missing scripts Console
SCORMValidationScene.unity 3 0 clean
XApiValidationRunner.unity 3 0 clean

This is worth measuring rather than eyeballing, because the failure it looks for is quiet. Both scenes once embedded render-pipeline components by GUID with no such dependency declared; Unity does not throw on that. It substitutes a null script, logs a warning that is easy to scroll past, and the component simply does not exist at runtime — so a customer's first contact with the samples is a scene that loads and does nothing. The check therefore walks every component on every transform looking for nulls, and separately subscribes to Application.logMessageReceived to catch anything at warning level or above, rather than trusting an exit code.

Reproduce it by opening each scene in a Built-in-RP project of the declared editor version; the batch-mode version used here lives in the throwaway CI harness (Assets/Editor/SceneOpenCheck.cs) and exits non-zero on any null script or console problem.

Preconditions

  • Both CI jobs green on the commit under test.
  • Both packages built through the packer, with Decompression Fallback on.
  • Both passing validate-package.py at 100% with no warnings.

What to look for

Ordered by how much it would cost to get wrong. Every step maps to a control that exists in the shipped validation panel — several of them had to be added, because the checklist previously asked for things the panel could not do.

# Do this Expect Guards
1 Launch the SCO Session initializes; log shows LMSInitialize/Initialize returning true API discovery, version match
2 Read Launch Context Entry, Credit and Mode reported. PassingScore is null The in-box packer emits no mastery score, so this is a documented limitation — confirm it rather than assume it
3 Write Score, Read Score Values round-trip. On 2004, cmi.score.scaled is written alongside raw/min/max, and Read Score reports it M-5. The button goes through SetScore, which is what derives scaled; setting the three score properties directly does not, and a run against a panel that did so measured scaled at 403, value not initialized
4 Set Passed, then Set Completed The pass survives. 2004: success_status stays passed. 1.2: lesson_status stays passed The pass→completion rule, on both standards. 1.2 used to overwrite it with completed, following the finish sequence the FAQ recommends
5 Set Failed, Read Status Fail recorded; completion follows the configured failedImpliesCompleted policy Status axes
6 Write Objective Score Score elements only. No success_status, no completion_status, no objective success C-1 — a score of 20 was once recorded as a pass
7 Write Objective Success, then Write Objective Completion, then Read Objective Each writes its own element. On 1.2, completion warns and writes nothing C-1, and the 1.2 objective-completion limitation
8 Write Interaction (correct) type is choice on both standards; result is correct; no 405 on the type write multiple_choice was written on 1.2 — a token in neither standard
9 Write Interaction (incorrect) result is incorrect on 2004 and wrong on 1.2. No 406. The instructor report shows the answer judged wrong, not unjudged The most important step here. Every incorrect answer used to lose its result on a conformant 2004 LMS while correct answers recorded fine
10 Write Interaction (correct) again, then Read Interaction One record per id, updated in place — not two appended Interaction dedupe
11 Write Out-Of-Range Score (150 of 0–100) Per standard. On 1.2: refused, and the console names the right meaning. On 2004: accepted — the data model puts no range on cmi.score.raw. The 2004 range check is cmi.score.scaled, which refuses 1.5 and −2 with 407 M-1 — the error tables and the constrained elements both differ per standard. Measured: §3
12 Write Suspend Data, Commit Accepted; no 405; the commit returns true and appears in the log H-1, and commit is the persistence moment
13 Set Incomplete first, then Exit = Suspend, Finish; relaunch; Read Launch Context, Read Suspend Data cmi.entry reads resume; bookmark and suspend data restored intact #19, suspend/resume. The incomplete write is a precondition, not decoration: a 1.2 attempt whose SCO never set lesson_status is completed by the LMS on exit, and the relaunch comes back in review mode with entry empty. Measured on SCORM Cloud
14 After that resume: Write Interaction (incorrect), then Read Interaction The write succeeds and the index is correct On platforms that journal interactions per session, the 1.2 local cache is not reconciled against cmi.interactions._count — a failure here is a known issue to fix, not a surprise
15 Exit = TimeOut, Finish cmi.exit accepts time-out on both standards. No 406 The 2004 runtime wrote timeout; this was the one open question in the SCORM path and is now resolved from the specification. Confirm it
16 Exit = Logout, and Exit = Normal (separate registrations) Both accepted. Normal writes "", which ends the attempt Exit vocabulary
17 Finish, then press any write button Writes refused locally and never reach the LMS. No 133/301 in the log H-9
18 Set some state, then close the tab without pressing Finish The log shows a Commit at unload; progress retained on relaunch H-2, and the page helper is the only thing that does this
19 Complete normally, Finish Terminate/LMSFinish returns true; the registration shows complete with the expected status and score End to end

Out of scope

Multi-SCO packages, sequencing and navigation, attempt management, and the xAPI State and Profile APIs. All four are documented as not covered. The xAPI path is validated separately with XApiValidationRunner against a real LRS.

Recording the result

Fill this in. A conformance run that nobody wrote down has to be done again.

Field SCORM 1.2 SCORM 2004
LMS platform SCORM Cloud (Rustici Engine 22.38.484) SCORM Cloud (Rustici Engine 22.38.484)
Standard as imported SCORM 1.2 SCORM 2004 3rd Ed.
Manifest parser "Congratulations, your manifest looks great!" "Congratulations, your manifest looks great!"
Package scorm12.zip, 4.7 MB, 18 entries scorm2004.zip, 4.7 MB, 18 entries
Third-run build rebuilt 2026-10-06 with the numeric-read fix; verified present in the shipped framework.js same
Built with Unity 6000.3.5f2, Gzip + decompression fallback Unity 6000.3.5f2, Gzip + decompression fallback
validate-package.py 23/23 23/23
Launch configuration content frame; API found via the parent chain content frame; API_1484_11 found via the parent chain
Date 2026-10-01, re-run 2026-10-06 2026-10-01, re-run 2026-10-06

Outcome: passed on both standards, with two defects found and fixed during the first run, two checklist errors found in the second, and one release-blocking defect found and fixed in the third. All of them are recorded below, because a conformance run that only records its passes is not evidence of much.

Coverage after the third run (2026-10-06): every step except 17 has been executed and passed on both standards, against the build that ships. Step 17 is not reachable on this platform; see Second run below for why, and for what covers it instead. The third run is recorded after that: it found that no previous run had read a numeric element back across a session boundary, and that doing so failed on both standards.

Results

# SCORM 1.2 SCORM 2004
1 Launch and initialize pass pass
2 Launch context pass — Entry AbInitio, Credit Credit, Mode Normal, PassingScore (not reported) pass — entry read directly
3 Score pass (2026-10-06, third run) — raw 85, min 0, max 100 written and read back through Read Score, both in-session and again after a resume. The 2026-10-01 pass was an in-session readback only; see Numeric elements read back as nothing for why that distinction turned out to matter pass (2026-10-06, third run) — the composed path writes scaled 0.85 alongside raw/min/max, all accepted, and Read Score reports all four after a resume. Recorded as a fail earlier the same day against the pre-fix build, where it reported raw=, min=, max=, scaled=
4 Pass then completed pass — status stayed Passed; LMS recorded Success passed, Completion complete pass (guarded by the same rule)
5 Set failed pass (2026-10-01, second run) — lesson_status = failed, score intact pass (2026-10-01, second run) — success_status = failed, completion_status = completed per the failedImpliesCompleted default
6 Objective score only pass pass — score.raw 85, success_status unknown
7 Objective success and completion pass — completion refused with the documented message pass — completion_status completed
8 Interaction, correct pass — type=choice, result=correct pass — stored as choice / correct
9 Interaction, incorrect pass — result=wrong pass — stored as choice / incorrect, no 406
10 Re-answer pass pass — three writes of one id, _count stayed 2
11 Out-of-range score pass — refused locally, LMS score stayed 85.00% run, and the expectation is wrong for 2004 — 150 of 0–100 was accepted, error 0. So was 99999. See below
12 Suspend data and commit pass pass — Commit -> True
13 Suspend, relaunch, resume pass (2026-10-01, second run) — cmi.core.entry = resume, cmi.core.lesson_mode = normal, bookmark and envelope byte-identical, interaction intact. Requires an explicit incomplete status first — see below pass — cmi.entry = resume, suspend_data byte-identical, interactions and objective intact
14 Post-resume interaction write pass (2026-10-06, third run) — index=1, cacheCount=2 from the envelope-restored cache, and twice in succession, so the write is idempotent. SF-1.3 stays deferred pass (2026-10-06, third run) — updated in place, _count stayed 2, no duplicate at index 2, no warning. Recorded as a fail earlier the same day against the pre-fix build, which refused cmi.interactions.1.correct_responses.0.pattern with 351
15 Exit time-out pass (2026-10-01, second run) — accepted, error 0. Measured directly: timeout and time_out are both rejected 405 pass — accepted, no 406
16 Exit logout and normal pass (2026-10-01, second run) — both accepted through the runtime. Measured directly: the literal normal is rejected 405; "" is accepted pass — both accepted
17 Writes after finish not run — not reachable on this platform; see below not run — same
18 Commit on tab close pass (2026-10-01, second run) — LMSCommit → true at unload, zero LMSFinish; bookmark and all three score elements persisted pass (2026-10-01, second run) — Commit → true at unload, zero Terminate; same elements persisted
19 Finish pass — registration Satisfied true, Completed true, Attempts 1 pass — Finish -> True

Read directly from the LMS data model at the end of the SCORM 2004 run, every read returning error 0:

cmi.objectives._count                 1
cmi.objectives.0.score.raw            85
cmi.objectives.0.success_status       unknown      <- a score write asserts nothing
cmi.objectives.0.completion_status    completed

cmi.interactions._count               2
cmi.interactions.0.id                 question_2
cmi.interactions.0.type               choice
cmi.interactions.0.result             incorrect    <- the 2004 spelling, accepted
cmi.interactions.1.id                 question_1
cmi.interactions.1.type               choice
cmi.interactions.1.result             correct

cmi.entry                             resume
cmi.suspend_data                      {"checkpoint":"ValidationCheckpoint","score":85}

What the run found

The diagnostics proxy was emptying LastError. The injected page helper built its proxy with Object.create(api) and gave it own properties only for the calls it wraps, so everything else ran with this bound to the proxy. SCORM Cloud's adapter keeps its error state in properties on this, so a single GetValue through the proxy gave it own copies of ErrorNumber, ErrorString and ErrorDiagnostic which then shadowed the adapter's for the rest of the session. Measured: the adapter holding 351 while the proxy answered 0.

ILmsRuntime.LastError and DescribeLastError() are documented public API that the README and FAQ both tell customers to check, and they returned "no error" for the whole session on this platform, in the default configuration. Every message the package produces that decodes that value said "OK" too. No offline test could have caught it: the JavaScript harness's adapter kept state in a closure, which is reachable whatever this is. The harness now has two adapters that keep state the way real ones do.

Re-answering a question failed on SCORM 2004. correct_responses is a collection and SCORM Cloud refuses a write to an index that already holds a pattern, with 351. Since recording an interaction updates the existing record in place, every re-answer hit it. The pattern is now written only when the record is created, which is also the only time it can be meaningful.

Both were found because this branch started checking the result of every write. Before that, both failed silently.

Second run — 2026-10-01, closing the gaps

The first run left steps 17 and 18 unrun on both standards and 13 to 16 unrun on SCORM 1.2. This run closed all of them except step 17. Packages were rebuilt from the merged main (both 23/23 on validate-package.py), so they carry the diagnostics-proxy fix rather than the code the first run exercised.

Method. The tracked sandbox launch, not the library's Launch link — that one is tracking=false and reports credit = no-credit, lesson_mode = browse, learner "Without Tracking, Preview", and persists nothing. Anything read from a preview launch is worthless as conformance evidence. For step 18 the LMS adapter was wrapped before the SCO frame was pointed at the content, so the injected page helper's proxy bound the instrumented methods; call records were written to localStorage so they survived the unload being measured.

Step 18 — the one that had never been checked. On both standards, with learner state written and no Commit pressed, navigating away produced exactly one commit and no terminate:

SCORM 1.2    LMSSetValue cmi.core.lesson_location|Validation/Location -> true
             LMSSetValue cmi.core.score.min|0   -> true
             LMSSetValue cmi.core.score.max|100 -> true
             LMSSetValue cmi.core.score.raw|85  -> true
             LMSCommit                          -> true     <- fired at unload
             LMSFinish   (never called)

SCORM 2004   SetValue cmi.location|Validation/Location -> true
             SetValue cmi.score.min|0   -> true
             SetValue cmi.score.max|100 -> true
             SetValue cmi.score.raw|85  -> true
             Commit                     -> true             <- fired at unload
             Terminate   (never called)

Relaunching each registration and reading the data model back returned lesson_location = Validation/Location and score.raw = 85, min = 0, max = 100 on both. The commit-only-no-terminate design behaved as documented, and on 2004 completion_status and success_status both stayed unknown — a score write still asserts nothing.

Step 17 is not reachable on this platform. SCORM Cloud's player navigates the window to its return URL within about one second of Terminate, which unloads the SCO and clears the console before a post-finish write can be attempted. A batched click sequence timed to land immediately after Finish arrived after the navigation had already happened. This is a property of the player, not of the package, and it is why the step went unrun the first time too. The behaviour it would check is covered off-platform at the adapter level: the JavaScript suite asserts [1.2] writes after finish never reach the adapter and the same for 2004, against a double that records every call, and ScormSessionGuardTests covers the same guard in EditMode. Those are stronger than the manual step, which can only infer the result from the absence of a 301.

SCORM 1.2 needs an explicit incomplete status to be resumable here. The first attempt at step 13 suspended without the SCO ever setting cmi.core.lesson_status. SCORM Cloud completed the attempt on exit, so the relaunch came back lesson_mode = review, credit = no-credit, entry = "" — not a resume. All the stored data was intact; the launch context was simply not a resumable one. Setting Incomplete before suspending produced entry = resume and mode = normal. This is the LMS auto-complete behaviour the markIncompleteOnInitialize setting exists to work around, and resumable content must set a status it owns rather than relying on the default.

The SCORM 1.2 interaction-cache reconcile stays deferred. Step 14 now passes on both standards: after a resume the envelope-restored 1.2 cache produced index=1, cacheCount=2, so the new interaction landed at the right index with no duplicate and no overwrite. The pre-identified conditional promotion of SF-1.3 is therefore not triggered.

A gap in the validation panel, not the runtime. Step 3 tells the tester to expect cmi.score.scaled alongside raw/min/max on 2004. The panel's Write Score sets Cmi.ScoreMin, Cmi.ScoreMax and Cmi.ScoreRaw directly, so it never goes through SetScore, which is the method that derives and writes cmi.score.scaled (Runtime/Extensions/LmsStatusExtensions.cs:136). Measured after a panel score write on 2004: cmi.score.scaled returned 403, value not initialized. The runtime is correct and the checklist is correct about what SetScore does; the panel could not demonstrate it. A tester following step 3 on 2004 would have recorded a failure that is not a runtime defect.

Fixed on 2026-10-02. WriteScore now calls SetScore(85f, 0f, 100f) and ReadScore reports scaled alongside the other three, so the step exercises the path it describes. Step 3 in the checklist above now names which path the button takes, and step 11 keeps writing ScoreRaw directly on purpose — it probes the raw element's own range behaviour, and SetScore would rescale the value on 1.2 instead of letting the LMS judge it.

What is proven, and what is not. Each link is evidenced:

  1. SetScore(85, 0, 100) writes cmi.score.scaled = 0.85 on 2004 — ScormScoreTests.Scorm2004_SetScore_AlsoReportsScaled, green at 336/336.
  2. The panel now makes exactly that call — source, compiled, suite green, package rebuilt at 23/23.
  3. SCORM Cloud accepts cmi.score.scaled = 0.85 — measured live on 2026-10-01 (§3), error 0, with 1.5 and −2 refused 407.

Third run — 2026-10-06, both standards against the shipping build

Driven to close the one open item the second run left: whether the composed SetScore path puts cmi.score.scaled on a live 2004 LMS. It does. It also found a defect that made the previous two runs weaker evidence than they appeared, and the whole checklist was re-run on both standards afterwards.

Confirmed on 2026-10-06, and it uncovered something worse. The rebuilt package went back through SCORM Cloud and the composed path held: the panel's Write Score put raw 85, min 0, max 100 and scaled 0.85 on the LMS in one live session, all accepted, error 0. Every link above is now observed rather than inferred, and the wire record is at the end of this section.

Numeric elements read back as nothing — found, fixed and re-verified on 2026-10-06. This is the most serious thing any run has found, and it was invisible from the LMS side throughout, because it affects only reads. Every write in every run landed correctly, which is why the platform's own reporting looked right while the package could not see what it had written.

SCORM says GetValue returns a characterstring. SCORM Cloud returns a JavaScript number for each numeric element and a string for everything else, on both standards. It only appears after a round trip through the server: inside a live session the player echoes back the strings the content just wrote, so an in-session readback passes even on a broken build. Measured after a resume:

Element SCORM 2004 SCORM 1.2
score raw / min / max number number
score.scaled number no such element
objectives._count number number
objectives.n.score.raw number number
interactions._count number number
the error accessor string number
ids, statuses, location, suspend data string string

The WebGL bridge sized every read with Emscripten's lengthBytesUTF8, which is documented @param {string} and does not coerce: handed a number it reads .length, finds undefined, skips its loop and returns 0. The C# side treats a zero length as an empty element and never makes the second call that would have fetched the value. So on this platform every numeric element read back as an empty string, and nothing anywhere reported an error.

What it cost, measured on the pre-fix build:

  • Step 3, 2004 — Read Score reported raw=, min=, max=, scaled= against a registration holding all four.
  • Step 14, 2004 — a 351 on cmi.interactions.1.correct_responses.0.pattern during the resumed attempt. With _count unreadable the runtime fell back to a hard-coded count of 1, examined only index 0, judged the existing question_2 to be new, and wrote it again as a new record.
  • Reachable but not reached in the run: a third interaction would have overwritten the second, and on 1.2 — whose objective path had no fallback at all — every objective after the first would have gone to index 0.

Both halves are fixed. The bridge renders a non-string value as text before measuring it, and both runtimes probe ids upward instead of guessing a count. Both fixes are covered by tests that were run against the unfixed code first and failed.

Re-verified live the same day, on both standards, after a resume:

SCORM 2004 SCORM 1.2
Read Score raw=150, min=0, max=100, scaled=0.85 raw=85, min=0, max=100
Read Objective objective_1, raw=85, Passed, Completed objective_1, raw=85, Passed
Read Interaction 2 of 2 listed 2 of 2 listed
post-resume interaction write in place, no 351 index=1, cacheCount=2

The 2004 figure reads 150 because that registration still carried the out-of-range value from step 11. That is the point: it is the real stored number, not an in-session echo of something just written.

The earlier records were not wrong about what they saw. The 2026-10-01 step 3 pass was a genuine in-session readback. What no run before the third one did was read a numeric element back across a session boundary, which is the only place this appears. A readback inside the session that wrote the value tests less than it looks like it does — and on 2026-10-06 the 2004 step 3 was first marked a pass on evidence weaker still: a direct read of the data model in the browser rather than the panel readback the step asks for. That substitution bypassed the exact path that was broken, and the run continued on a false green for thirteen more steps. Substituting a different check for the one a step specifies is how a conformance record stops being evidence.

SCORM 1.2 interaction elements are write-only. Measured 2026-10-06: LMSGetValue('cmi.interactions.0.id') returns 404 Data Model Element Is Write Only, even immediately after a successful write to it. That is the 1.2 data model rather than a platform quirk, and it is why the 1.2 runtime keeps a local interaction cache and mirrors it into suspend data. There is no way to verify a 1.2 interaction by reading the data model, so step 10 is checked through GetInteractions() and the envelope instead. cmi.interactions._count is readable, and is one of the elements that comes back as a number.

The wire record. Both standards, driven by hand through the Launch button: Write Score, Set Passed, Set Completed, Commit, Exit = Normal, Finish — read from the registration's own debug log rather than from our console.

SCORM 2004, twelve calls, every one true:

Initialize('')                                true
SetValue('cmi.exit', 'suspend')               true
SetValue('cmi.score.min', '0')                true
SetValue('cmi.score.max', '100')              true
SetValue('cmi.score.raw', '85')               true
SetValue('cmi.score.scaled', '0.85')          true
SetValue('cmi.completion_status','completed') true
SetValue('cmi.success_status','passed')       true
SetValue('cmi.completion_status','completed') true
Commit('')                                    true
SetValue('cmi.exit', '')                      true
Terminate('')                                 true

SCORM 1.2, nine calls, every one true:

LMSInitialize('')                              true
LMSSetValue('cmi.core.exit', 'suspend')        true
LMSSetValue('cmi.core.score.min', '0')         true
LMSSetValue('cmi.core.score.max', '100')       true
LMSSetValue('cmi.core.score.raw', '85')        true
LMSSetValue('cmi.core.lesson_status','passed') true
LMSCommit('')                                  true
LMSSetValue('cmi.core.exit', '')               true
LMSFinish('')                                  true

Three differences, all of them the standards' and none accidental:

  • score.scaled is written on 2004 and not on 1.2, which has no such element. The panel still shows a derived scaled value on 1.2; it is computed locally and never sent.
  • Set Completed makes no LMS call at all on 1.2. There is one status axis, so writing completed would destroy passed. The runtime declines the write rather than making it and hoping. This is the pass-survives-completion rule visible at the protocol level rather than inferred from a readback, and SCORM Cloud then derives Completion complete from passed by itself.
  • cmi.exit is set to suspend in the same millisecond as Initialize on both standards. A deliberate default, so an unexpected close is resumable — and it is what makes step 18 work.

Driving the SCO directly in the content frame is not equivalent to a real launch. The popup workaround used here points the player's content frame straight at the SCO. That is a realistic frameset configuration as far as the runtime is concerned, and everything the runtime does was measured through it — but it bypasses the player's own post-session flow. A session finalises server-side when it terminates through that flow: Finish pressed, player allowed to redirect. Two SCORM 2004 sessions were ended by navigating away instead, and those produced no client debug log and left the registration rollup at unknown despite Commit and Terminate both returning true. Step 19 on both standards was therefore driven by hand from the Launch button, and only then did the rollup move to complete / passed / 85.00%. If you repeat this checklist the way it was driven here, do step 19 by hand — and do not read a rollup that has not moved as a defect in the package until you have.

Step 11's expectation is wrong for SCORM 2004. The step tells the tester to expect an out-of-range score to be refused. On 1.2 it is: cmi.core.score.raw is typed CMIDecimal with a 0–100 range, and the first run recorded the refusal. On 2004 the same write is accepted, and so is a far wilder one:

Write Result Error
cmi.score.raw = 150 (with max = 100) accepted 0
cmi.score.raw = 99999 accepted 0
cmi.score.scaled = 1.5 rejected 407 Data Model Element Value Out Of Range
cmi.score.scaled = -2 rejected 407 Data Model Element Value Out Of Range
cmi.score.scaled = 0.85 accepted 0

That is conformant. SCORM 2004 puts no range constraint on cmi.score.raw — it is a real number, and the content owns its meaning. The constraint lives on cmi.score.scaled, which is bounded −1 to 1, and the LMS enforces it exactly. The checklist's own guard column already says "the error tables differ per standard"; the expectation column does not, and should.

Two consequences worth stating plainly. First, step 11 should expect a refusal on 1.2 and acceptance on 2004, with the scaled rows above as the 2004 range check. Second, this sharpens the SetScore gap: cmi.score.scaled is the one score element a 2004 LMS actually validates and the one most platforms compare against the scaled passing score — and it is precisely the element the validation panel never writes. The runtime writes it through SetScore; the panel does not use SetScore.

3. Data-model enforcement — measured

A second, narrower run on 2026-10-01, probing the data model directly rather than through a course. The point was to establish what a strict SCORM 2004 LMS actually accepts, because three of this package's design decisions rest on the answer and none of them is settled by reading the specification alone.

Platform: SCORM Cloud, SCORM 2004 3rd Edition. Method: API_1484_11 called from the player window, each write followed by GetLastError(), GetErrorString() and GetDiagnostic().

Write Result Error
cmi.completion_status = completed accepted 0
cmi.completion_status = passed rejected 406 Data Model Element Type Mismatch
cmi.completion_status = failed rejected 406 Data Model Element Type Mismatch
cmi.success_status = passed accepted 0
cmi.score.scaled = 0.85 accepted 0
cmi.interactions.0.id, .type, .result = incorrect accepted 0
cmi.interactions.1.result = wrong rejected 406 Data Model Element Type Mismatch

The LMS's own diagnostic on both rejected status writes, verbatim: "The completion_status data model element must be a proper vocabulary element."

The first and fourth rows are the controls. Writes to the same element, and to the neighbouring one, succeeded in the same session, so the rejections are the vocabulary being enforced rather than a broken session.

Exit vocabulary across both standards — 1.2 measured 2026-10-01, 2004 completed 2026-10-06

The table above covers SCORM 2004's constrained elements. The exit element was measured separately on 1.2, the same way: API called from the player window inside a tracked sandbox launch, each write followed by LMSGetLastError() and LMSGetErrorString(). It was assumed at the time that the two standards agreed here. They do not, and the correction is below the table.

Write to cmi.core.exit Result Error
suspend accepted 0
logout accepted 0
time-out accepted 0
"" (empty — how Normal is expressed) accepted 0
timeout rejected 405 Incorrect Data Type
time_out rejected 405 Incorrect Data Type
normal rejected 405 Incorrect Data Type

Three things follow, and all three match what the runtime does.

time-out is hyphenated on 1.2 too. The unhyphenated spelling the 2004 runtime used to write is refused here as well, so the bug would have been a bug on both standards had the 1.2 path shared it. The condensed third-party chart that prints timeout is wrong for either standard.

normal is not a token. LmsExit.Normal maps to the empty string, and the literal word is refused. An implementation that wrote the enum name would end every attempt with a refused exit write and fall back to whatever the launch default was.

The accepted set is not identical across the two standards — this document said it was, and that was wrong. suspend, logout, time-out and "" are accepted on both, and that much was measured correctly on 2026-10-01. What was never measured was the literal normal on 2004. Measured on 2026-10-06, the same way, on both:

Write to the exit element SCORM 1.2 SCORM 2004
normal rejected, 405 accepted, 0
"" accepted, 0 accepted, 0
suspend / logout / time-out accepted, 0 accepted, 0
timeout / time_out rejected, 405 rejected, 406

So 2004 accepts a token 1.2 refuses, and the two standards do not even agree on the error code for the spellings they both refuse.

The conclusion the old claim was supporting still holds, for a better reason than the one given: ScormStatusMap does not translate exit tokens per standard because LmsExit.Normal maps to "", which is the intersection of the two vocabularies rather than the union. Writing the literal normal would work on 2004 and end every 1.2 attempt with a refused exit write. The shared switch is correct; the stated reason for it was not.

What this establishes

Completion and success are not interchangeable on 2004. passed and failed belong to cmi.success_status, and cmi.completion_status refuses them. An implementation that folds the two 2004 axes back into SCORM 1.2's single lesson_status cannot report a pass on 2004 at all — the write is refused at the point of the call, before any question of persistence arises. This is why IScormDataModel carries CompletionStatus and SuccessStatus as separate values rather than projecting one onto the other, and why a SCORM 1.2 Completed write is suppressed once success is on record instead of being routed through a shared axis.

The interaction vocabulary genuinely differs by standard. wrong is valid 1.2 and is refused by 2004, which wants incorrect. ScormStatusMap.InteractionResult translates for precisely this reason. Without it, a course written against 1.2 vocabulary loses every interaction result on 2004 — and loses it quietly, because the rejection is visible only to a caller that checks.

A refused write is invisible unless the API surfaces it. Every rejection above returned false and set error 406, with a diagnostic naming the problem. An API whose setters return a bare boolean and expose no error accessor leaves the caller unable to tell "refused" from "refused for a reason you could fix", and the diagnostic reaches only the browser console — which nobody is watching on a deployed course. ILmsRuntime.LastError and DescribeLastError() exist for this, and section 2 records what happened when they were themselves broken.

Caveat

These are validation results at the point of the call, which is all the three conclusions above rest on. The probe drove the API directly without a SCO loading, and the sandbox registration summary read unknown both before and after, so nothing here is evidence about persistence or rollup. Section 2 covers that, through a real course.