DEV Community

caspervg
caspervg

Posted on

Every single disaster was an effect

How SimCity 4's effects system was built, what the modding community worked out about it over twenty years, and what changes once the packed resource can be read and rewritten.

If you want to add a new visual effect to SimCity 4, you run into a problem that has nothing to do with art. The stock startup path does not parse effect source files. It loads the standard Maxis catalogue from one packed resource, EA5118B0-EA5118B1-00000001, inside SimCity_1.dat. The particle systems, decals, screen shakes and lights used by the standard effects are stored as records in that resource. A disaster can combine many of those records. The resource carries no manifest, no offset table, and no shipped copy of the text files from which it was compiled.

That had been the situation since SimCity 4 was released in 2003, yet the community still built substantial mods by copying and editing the packed resource. I approached the problem from two sides. In April, I recovered the text language. In August, I followed the binary reader and writer. Once the decoder could open old community packages, those two lines of work met: the mods answered questions the disassembly had left open.

Before the file was packed

The effects system was written by Andrew Willmott, who joined Maxis in 2001 and stayed until 2013. SimCity 4's code name was Timbuktu, and two of his presentations about the system survive, one from the SC4 era and a later overview of what it had become by the time of Spore. Both are on the Internet Archive, and between them they answer more questions about the binary format than any amount of disassembly would have.

The design is stated on the first slide of the Timbuktu deck. It is a scriptable system for defining visual effects, where an effect is a collection of component effects such as particle systems, decals, sounds and sequences, each of which can be individually placed and oriented inside its parent. Effects are themselves components, so they nest. The authoring loop is one line:

Scripts are live: the game or viewer updates as soon as you hit save. There is no UI beyond that. (A. Willmott)

The scope inside SimCity 4 is wider than "particles". Willmott's own list runs to atmospheric effects including weather and clouds, bits of the user experience (i.e. dust clouds when you plop a building, and coverage rings), fire, boats, planes and building destruction. Then one line on its own:

Every single disaster was an effect. (A. Willmott)

The same system was used in several other Maxis games. The Sims 2 shipped in September 2004 with 39,000 lines of effect script covering object effects, thought balloons, build-mode cursors and camera scripting. At GDC in March 2005, Swarm was responsible for 70% of Will Wright's Spore demo, which Willmott notes fooled a lot of people into thinking the game had already been built.

Two further details from the slide decks are worth carrying into the rest of this. The first is a prototyping story. Under "Quick Prototyping", Willmott lists that the effects system ended up driving all SC4 boats, including shore avoidance, and all planes and their paths, takeoff and landing included. The system was not designed for that. It was reached for because it was the fastest way to get behaviour into the game without waiting on engine code, which is also how a particle library ends up containing a component that demolishes buildings.

The second detail is the decision that created the problem this post is about. From the Swarm overview:

Data scripted with hot loading. Not interpreted: no run-time script parsing in final game. (A. Willmott)

Scripts were live during development and compiled for release. The compiled artifact is the resource above, and the sources were never released to the community.

Twenty years of clues

Nobody starts from zero on SC4 in 2026, and this resource in particular has been picked at for the better part of two decades.

The SC4D Encyclopaedia's EFFDIR page is the reference everyone starts from. Built from earlier reverse-engineering work, it described the resource as fifteen numbered sections with DWORD-labelled fields and end-of-section markers. Its mixture of exact observations and tentative labels is consistent with opening the vanilla file, counting words, changing one value, and running the game.

I compared the old page with the resource serializer recovered from the symbolized Mac build. The old page got the skeleton right: its fifteen sections appear in the same order as the groups written by the serializer. Most of them do map onto a C++ class recovered from symbols (which were left in the original MacOS PowerPC and x86_32 ports by Aspyr):

Wiki section Recovered class
1: Main / terrain-independent cSC4ParticlesDescription
2: Terrain-dependent FSHs cSC4DecalDescription
3: Screen shake cSC4ShakeDescription
4: Saturation and lightness cSC4LightDescription
5: Brush exemplars cSC4BrushDescription
6: LUA occupant groups / attractors cSC4AttractorDescription
7: Destructive effect properties cSC4ScrubberDescription
8: Randomized picks cSC4SequenceDescription
9: Sounds cSC4SoundDescription
10: Effective radius? cSC4CameraDescription
11: UDI collisions? cSC4DynamicParticleDescription
12: Main script index cSC4EffectDescription
13, 13.5, 14, 15 name map, trailing floats, effect-key map, message triggers

Some individual findings are exactly right, and several were verified independently here. The wiki's decal repeat flag, for example ("01 = repeat continuously, 02 = run once then disappear, 03+ = run once then hold") is precisely what the decal runtime does with a single mode byte. Its saturation-and-lightness section is, as far as I can tell, a complete and correct description of the light record. Its "2 bytes random, 2 bytes random chance" are the selection group and probability that back the language's select block.

This method had a clear limit. Everything on the old Wiki page was a DWORD, because a hex editor shows you words, while the real stream has separate byte, u16, u32, f32 and string operations. Records such as the camera descriptor therefore cannot be expressed in that notation at all. The end-of-section markers are a related misattribution. The wiki recorded them faithfully, because they really are in the file, but most are fixed words that the writer emits between groups rather than structural terminators.

One line in section 1 illustrated the ceiling most clearly. After the first behaviour word, the wiki listed:

DWORD (0x00000000) <<<---Constant
Enter fullscreen mode Exit fullscreen mode

That word is not a constant. It is an eight-bit flag set (the second of the particle descriptor's three), and its bit 0 is the collision option that lets particles destroy buildings. It reads as a constant because the examined Maxis resource never sets that bit. One field therefore carries the whole difference between a description of a file and a description of a format. The new documentation, which is now available on the wiki page, separates wire layout, parser names, runtime behaviour and inference.

What was hiding in the mods

Xannepan, who releases as JENX, built three suites that live largely in this resource. AURORA is a celestial controller covering sunrise, sunset, aurora borealis and shooting stars. JUPITER is a weather controller simulating seasons and weather, with rain, snow, rainbows, optional tornados and lightning, including a destructive variant in which fewer than one strike in ten actually damages anything. POSEIDON adds water effects such as waterfalls. They are distributed as lots you plop, after which the controller runs by itself, and SC4Evermore now carries them as the SC4D LEX Legacy AURORA-JUPITER-POSEIDON pack.

JENX JUPITER screenshot by jason leo on Simtropolis: https://community.simtropolis.com/profile/489975-jason-leo/
JENX JUPITER screenshot by jason leo in the "Show us your Jenx Weather Mod Screenshots" thread on Simtropolis.com

I expected the JENX packages to be variations on the Maxis catalogue. The first decode showed something else. The contrast with gizmo28 makes it clear. He ships one EFFDIR at instance 00000001, the vanilla TGI, carrying the complete Maxis catalogue: 1,192 particle descriptions, 310 decals, 21 shakes, 25 lights, 305 sounds, 1,154 effect descriptions, 82 effect keys and both message triggers, at version 4.2. It is the whole resource, edited and shipped back. JENX takes the other road entirely, shipping fifteen small EFFDIR resources at instance ids of his own (weather and sky ids between 00001013 and 00001032, plus 00006000 through 00006002 for water), each holding one phenomenon. The multiple-resources note in the wiki's first paragraph is not a curiosity, then. It is the architecture one of these mods is built on.

Inside JUPITER, one resource per phenomenon does the work: 00001013 is lightning, with 17 particle systems and 7 sounds; 00001027 is the tornado, with 24 particles, two sounds and a camera; 00001025 holds clouds, fog and rainbows; 00001016 holds snow cover. Then 00001030 contains 27 effect descriptions and no components whatsoever. It is a pure composition layer, and it is where the seasonal behaviour lives.

The readme documents that behaviour as a table of monthly odds. In December, 80% dense clouds with a snowstorm and snow cover, or 10% light clouds with a snow flurry. Those numbers are in the binary, as probabilities on selection-group children:

jenx_weather_dec
  -> jx_weather_denseclouds_snowstorm_snowcover   selgrp=1  prob=52400  (80.0%)
  -> jx_weather_lightclouds_snowflurry80perc      selgrp=1  prob= 6550  (10.0%)

jx_weather_denseclouds_snowstorm_snowcover
  -> jenx_snowstorm                               selgrp=0  prob=    0  (always)
  -> jenx_snowcover_long                          selgrp=1  prob=32767  (50.0%)
  -> jenx_snowcover_short                         selgrp=1  prob=32767  (50.0%)
  -> jenx_denseclouds_snowdark                    selgrp=0  prob=    0  (always)
Enter fullscreen mode Exit fullscreen mode

The readme's "either 1.5 or 2.5 months of snow cover, random chance" is that 32767/32767 pair. Its "10% tornado or 30% funnel clouds" is a two-child group at 6550 and 19650. Every month in the table matches. The probability field is a 16-bit share of 65535, and a composed weather system is a two-level tree of name references with weights, which is select doing exactly what it was built for.

The destructive and non-destructive JUPITER variants make the point even more precisely. The two packages are byte-identical except for 95 bytes in each of two resources, and the decoder localizes the difference to one thing: the destructive build adds exactly one child record, of component type 5, to jx_lightningstrikefx_sub and to jx_tornadofx_sub. Component type 5 is the scrubber, the demolition component named on Willmott's slide. On the lightning effect that child carries probability 6553, which is 10.00% of 65535, and the readme's line is that fewer than one strike in ten is destructive. Destructive weather is one scrubber reference and one probability.

The same resource settles a documentation question by arithmetic. jx_lightningstrikefx_sub picks among seven bolt variants in one selection group, with weights 9362 six times and 9363 once, summing to exactly 65535.

POSEIDON is the same design applied to water and terrain. Its core resource pairs 13 decals with 30 brush records, and its effect names are jenx_beach_paint, jenx_rock_paint, jenx_lake_paint, jenx_pebble_paint and jenx_dry_grass_paint. Terrain brushes were the first bullet on Willmott's game-specific components slide, and here they are twenty years later painting beaches.

The compiler was never gone

To make editing effects resources in SC4 convenient, it needs a decoder that can rewrite the whole file rather than patch it, and preferably a way to author effects as text again. The code for both is actually still in the game.

The retail executable contains a working text parser and serializer for the effects language. cSC4EffectsParser is there, with its command classes, of which the SC4 Effects Extension DLL documentation now cites 49 distinct parse handlers by address, and even that is only the subset needed to explain wire fields. So is the writer that produces the packed resource. So is testEffect, an author-side command that spawns an effect during parse and throws No such effect if you misspell the name, which is a debugging convenience with no business surviving into a shipped game build.

Better, the language has commands for exactly the packed-resource workflow modders lack. loadResource imports another packed effects resource by instance id, which is what the wiki's note about multiple coexisting EFFDIRs looks like from inside the parser. And effectsResource <iid> <name> ... end opens or creates a packed resource, parses its body against a resource-backed collection, and saves it back out at the end of the block.

Nothing invokes any of it, though, because the bootstrap path loads the packed resource and never queues any files, and the save path is gated twice over. The surviving evidence does not require a separate visual editor: the authoring loop could use the game or viewer, a text file and the save path described in the presentation. To recover that loop, four inline DLL hooks against the Windows v1.1.641 (Digital Deluxe) binary of SimCity 4 are enough: the bootstrap loader, the parser constructor, the queued-file parse pass, and the bootstrap's file-existence check. From there the plugin queues .fx files from the user Plugins directory, forwards the parser's error strings into an in-game console, and calls the parser's own save-enable method so the recompiled catalogue can be written back out as a DBPF package.

What the source looked like

The grammar is one line from Willmott's scripting slide, which he credits to scripts used in animation production:

command arg1 arg2 -switch1 -switch2 switch2arg

blockCommand
    ...
end
Enter fullscreen mode Exit fullscreen mode

Blocks open with a command and close with end. Named definitions come in seven top-level families (effect, particles, dynamicParticle, decal, shake, light and sequence), several of which support inheritance. The presentation's own example

particles smokeDark : smoke
    color (0.2, 0.2, 0.3) (0.1, 0.1, 0.1)
    alpha 0.1 0.2 0.2 0.1 0
end
Enter fullscreen mode Exit fullscreen mode

is, feature for feature, a valid program under the recovered grammar. The slide's remark that almost any variable can be specified as a curve helps explain the repeated vectors in the binary. Size, aspect, rotation, alpha, light strength, shake amplitude and frequency use vectors of scalar samples, and colour uses vectors of three-component samples for R, G and B.

Above that base there is more machinery than I expected, such as typed variables with parse-time scaling helpers, namespaces, parameterized macros with their own substitution syntax, and an eval that re-parses exactly one command after substitution. There is even a loop of sorts, arrayCreate, which expands a named macro N times and passes each iteration its index and the total count.

And that is where the language stops. There are no conditionals, no recursion, and arrayCreate cannot loop over an arbitrary body, only over a macro that was already registered. Willmott's closing Swarm slide turns this apparent gap into a design statement:

For Artist-driven scripting of procedural systems, avoid logic scripting! Too much unnecessary, irrelevant complexity. Too easy to cause performance problems. Maintainability nightmare. (A. Willmott)

The missing if therefore appears to be a deliberate limit rather than an unfinished feature. It also explains why the compiled resource is a flat set of records with no bytecode. The architecture slide says scripts are all parsed into description blocks and there is no live script logic, and the classes in the binary are named cSC4ParticlesDescription, cSC4DecalDescription, and so on. Most of the wiki's sections correspond to description collections in the serializer's fixed order. The final sections contain name maps, effect keys, message triggers and optional metadata rather than one description class per section.


Early experiments with the recovered effects language and an ImGui-based editor running inside the game through a DLL built on the gzcom framework.

Following the writer

The binary work came down to two functions: the resource reader and its writer. They walk the object graph in a fixed order, and that order is the format. The Mac build made the first pass possible because it still carries C++ symbols. Instead of an unnamed function at an address, there was a stream operator for cSC4ParticlesDescription.

The Windows build then supplied the check. Its screen-shake path, for example, uses the same member offsets as the named Mac function. Each field was tagged by what evidence supports it: the read/write operators establish the wire layout, parser commands supply authoring names and semantics, and runtime consumers establish behaviour.

Why this became possible now

There was no single missing insight before. The problem was simple bookkeeping. The particle descriptor alone has 51 bits spread across three flag words and about seventy serialized members. For each one, somebody has to find the parser command that writes it, the runtime code that reads it, and any storage it shares with another command. Just one missed offset can leave a convincing but wrong table.

Ghidra supplied readable decompilation. MCP tools made the game binaries and DBPF resources queryable from the same working session: the agent could request a function by address, extract the EFFDIR, inspect its compression metadata and compare the decoded records without manual copying between programs.

The LLMs handled the dull passes. They enumerated command registrations, followed each parse handler to a member offset, and then searched in the other direction for runtime reads of that member. The same LLM editing sessions kept the new command reference pages aligned while the names changed and interpretations changed. But of course, that speed comes with an obvious failure mode. Give the model a plausible but wrong field name and it can repeat the mistake across code, tables and prose before anything looks inconsistent. I hoped to reduce the possible damage by meticulously having it document code addresses and explicit evidence labels, both in the documentation pages and in the Ghidra database.

The old notes answer back

The serializer left one awkward gap. Every child inside an effect carries a component-type byte, but only the dynamic-particle command exposed a value that I could trace directly. The old wiki had all ten values in its Index Flags table, including the odd dynamic-particle entry, and that one independent value agreed.

Then JUPITER supplied a wider check. Its tornado resource uses types 0, 1, 2, 4, 5, 7 and

  1. The counts line up: one type 1 against one decal, two type 7 records against two sounds, one type 8 against one camera, one type 4 against one attractor, and one type 5 against one scrubber. The lightning resource has seven type 7 children and seven sound records.

Seven of the ten values therefore occur in independent data. Five have direct count matches. Type 2 remains an opaque runtime component rather than a collection index. That is a good result for a table assembled by counting bytes roughly fifteen years ago.

Willmott's presentations and slides answered a different set of questions. The wire format has six component vectors, each a class recovered by symbol: brush, attractor, scrubber, sequence, sound and camera. The Timbuktu deck has a slide titled "Game-specific Components" whose bullets are terrain brushes, automata attractors, pause and unpause Simulator, grab and manipulate camera, pool water, and game messages. Read against the recovered records, that slide reads as a legend for half the format:

Slide bullet Recovered record or flag
Terrain brushes the brush record, with apply mode, zoom, strength, width and level
Automata attractors the attractor record, plus the automataEffect command
Pause and unpause Simulator scrubber flags pauseSim, pauseSimHidden, pauseClock
Grab and manipulate camera camera flags zoom, rotation, target, slave
Pool water the decal flag that selects the water overlay manager
Game messages the message-trigger record, plus the scrubber's message arguments

The scrubber is named outright in the Swarm presentation, under game-specific components: "SimCity: Scrubber, for demolition". That is the record the wiki called "Destructive Effect Properties", and the one a destructive-lightning mod would need.

What is still missing

The decoder still reaches several blank spots. The scrubber's demolition word combines options whose bit layout is not established, the decal draw enum has an unknown numeric range, and several particle values have no traced parser setter or runtime reader.

The Timbuktu slide deck's second-to-last slide, right before the acknowledgements, is titled "Disadvantages":

Need better monitoring and debugging facilities. Too easy for a (script) error in one effect to cascade to others. (A. Willmott)

The slide is a two-line statement from around 2004, written by the person who built the system, and a fair description of what is still missing. Parser errors now report on their source lines, next to an event log and effects-manager statistics. The editor has a diagnostics panel and refuses to guess. Cascade isolation, however, is still absent.

For an effects author, the result is practical. The resource can be decoded and rebuilt, so records can be added without counting bytes. Unknown fields survive the trip, which makes a change reviewable without replacing the whole Maxis catalogue.

JUPITER already proved that effects can ship in a private EFFDIR. The new part is the toolchain around it: an author can start from a small resource, edit source text, run the game's compiler, inspect the packed output and revise it without returning to a hex editor.


Acknowledgements

Claude Code and Codex assisted with tracing decompiled code, comparing decoded resources, maintaining the documentation, implementing supporting tools and DLL extensions, and editing this article. I checked the technical claims against the game binaries, parser and serializer code, and decoded EFFDIR resources. The documentation marks fields that remain unknown or depend on inference.

References and further reading

The exact record counts and package comparisons in this article were produced with the EFFDIR Editor from this repository.

Top comments (0)