JRL Format (Journal)
A .jrl file is the quest journal: a list of categories, each a quest with its own list of numbered entries. Entry text is what the player reads in the journal UI, and the entry number a script sets is what selects which text is shown and whether the quest reads as finished.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .jrl |
| Magic Signature | JRL / V3.2 |
| Type | Quest journal |
| Rust Reference | No typed view. Read as a generic GFF tree via rakata_formats::Gff. |
Population: four files in a retail install, so they are named rather than counted. global in data/_newbif.bif, and a module.jrl in each of modules/tar_m02ab_s.rim, modules/tar_m02ad_s.rim and modules/tar_m04aa_s.rim. The last of those carries an empty Categories list and nothing else.
Warning
A module’s own
.jrlis never opened. Onlyglobal.jrlis. Every quest-state change in the game funnels through one function, and the journal it opens is built from the literal stringGlobal. Not the current module’s resref, not a parameter, not a lookup: a constant. No branch in that function can open any other.jrl.So the three module journals shipped in the Taris archives are dead content, and so is any journal a mod adds to a module. Editing
<modulename>.jrlchanges nothing in the game, and nothing in the toolset or the file will tell you. Quest content belongs inglobal.jrl.There is nothing to say about how the two get merged, because they never do, and nothing to say about module files separately. Everything below describes
global.jrl.
Picture is kept up to date and never read back
Everything about this field says it is live. The engine writes it, and it has a message handler of its own to push it from the server side across to the client, which is real work nobody does for a value that does not matter.
Nothing then reads it. Exactly two functions in the whole executable can hand out a pointer to a journal entry, and between them they have exactly one caller: the quest-state function this page is about, which reads ID, Text, XP_Percentage and End and never touches Picture. There is no third way to reach an entry, so there is nowhere the field could be read from.
This is the strong claim rather than the weak one, earned by reach rather than by name. The name test cannot settle this one: the engine writes and syncs the field, so its label is unmissable in the binary. What is exhaustive here is the reach. Enumerate every function that can produce an entry pointer, enumerate their callers, and the set closes with no reader in it.
So this is not a field nobody touches. It is a field the engine bothers to keep current and then never consults, at least in the shipped client. Keep it on a round trip, because the engine does. Both files that carry it hold 65535, which has the shape of a sentinel, and with nothing reading the field there is nothing that could be testing it, so it stays recorded as a value.
File Layout
One list at the root. Each element is a quest; each quest owns a list of entries.
| Label | Element |
|---|---|
Categories | A quest: identity, priority, planet and plot linkage, and its entries |
Categories[].EntryList | One journal entry: its number, its text, and whether it closes the quest |
Field table
Every read below happens inside the single state-setting function, each with an explicit default supplied at the read site, so an absent field takes that default rather than following a separate code path.
| Label | GFF type | Absent-value default | Read? | Notes |
|---|---|---|---|---|
Categories | List | empty | Yes | |
Categories[].Tag | CExoString | empty string | Yes | What a script names the quest by |
Categories[].Name | CExoLocString | empty | Yes | |
Categories[].Priority | DWORD | 0 | Yes | Shipped values are 0 through 4 |
Categories[].PlanetID | INT | 0 | Yes | -1 means no planet. See below. |
Categories[].PlotIndex | INT | 0 | Yes | A plot.2da row key, not a sentinel. See below. |
Categories[].Comment | CExoString | n/a | No | Design notes left in by the toolset; never read |
Categories[].Picture | WORD | 0 | Written, never read | Maintained and synced, and no code path can read it back. See above. |
Categories[].XP | DWORD | n/a | No | Inert. The award comes from XP_Percentage. |
Categories[].EntryList | List | empty | Yes | |
Categories[].EntryList[].ID | DWORD | 0 | Yes | The entry number a script selects |
Categories[].EntryList[].Text | CExoLocString | empty | Yes | |
Categories[].EntryList[].XP_Percentage | FLOAT | 0.0 | Yes | Drives the award. See below. |
Categories[].EntryList[].End | WORD | 0 | Yes | Tested against bit zero. See below. |
Rules the engine enforces
Important
Endis tested against bit zero, not for non-zero The value is masked with1and only that bit is carried into the entry’s flags. Shipped data holds0or1and behaves as a boolean either way, so the distinction is invisible in the corpus and matters only to a writer choosing a value.It matters because the obvious generalisation is wrong:
2does not close the quest.2 & 1is zero, so a file writing2for “true” produces an entry the engine treats as open. Write1.
The XP an entry awards comes from XP_Percentage and a 2DA row. It does not come from XP. Where an entry carries XP_Percentage, the engine takes the quest’s PlotIndex as a row number into plot.2da, reads the XP column there, multiplies it by the percentage, rounds, and that is the award. The entry’s own XP field is never read at all, so the 0 sitting in every shipped copy of it means nothing either way.
Which also tells you what PlotIndex is: a table key, used as one every time, never checked for a special value. A -1 there does not mean “no plot”. It means row -1 of plot.2da, and the lookup simply fails.
Warning
Setting a state to an entry number that does not exist does not fail This is the format’s central operation, and its failure mode is silence. The state-setting function searches the category’s
EntryListfor an element whoseIDmatches the number a script asked for. If the search runs out without a match, or the list is empty, execution falls through to the same tail a successful match reaches: the category’s changed bit is set and the player gets a journal-updated notification.So the quest appears to update. What is skipped is only the entry-level part:
Text,XP_PercentageandEndare never touched, and whatever they held before, whether construction defaults or a previous call’s values, is what a client displaying that entry shows. Nothing errors and nothing records that the requested number was missing.For a writer this makes a typo in an entry number a bug with no symptom at the point of failure. The quest advances in the journal UI and its text does not change.
Note
Category tags are compared case-insensitively, not stored lowercased Every tag lookup here goes through a case-insensitive string comparison: against categories already tracked at runtime, and against the
Tagcolumn of the journal file’s ownCategorieslist. The stored strings are never modified.That is a different mechanism from the convention
are.mddocuments for area tags, which lowercases once at storage and then compares for plain equality. Both reach “case does not matter” for ordinary tags, so the distinction only shows up at the edges, with unusual casing or non-ASCII bytes, where normalising and comparing leniently are not the same operation.A lowercased copy is made elsewhere in the same function, but only for a network-sync message payload. Nothing matches against it.
No entry is numbered 0, which is what keeps 0 free to mean “not started”. Entry numbers in the .jrl files a retail install ships run from 1 to 150 and never include 0. They also sit overwhelmingly on multiples of ten, which is a habit of the content and not a rule of the format.
That matters because the quest state a save carries is an entry number. Across a local corpus of saves, every JNL_State value in every PARTYTABLE.res is a number that occurs as an entry ID somewhere in the shipped journal, and none of them is 0. So the two ends agree, and a 0 cannot be confused with a quest sitting at its first entry.
Two limits on that. The saves are modded, so they establish the shape of the field rather than anything about vanilla progression. And nothing here establishes what the engine would do with an entry numbered 0, only that no shipped file writes one and no save stores one. A writer adding quests should keep away from 0 for the same reason the shipped content does.
PlanetID’s -1 is a real sentinel, and the test for it lives in the UI rather than where the field is read. The journal checks for -1 before doing anything with the value, and where it finds something else it reads the Name column of planetary.2da at that row to put the planet’s name in front of the quest. The comparison is signed, so this is an ordinary equality sentinel.
Engine Audits & Decompilation
Read from CSWSJournal::SetState at 0x005c5a40 in swkotor.exe. Provenance: traced, and the Picture finding is traced across the whole executable rather than along one path.
| Pipeline Event | Engine Behaviour |
|---|---|
| One funnel for every write | Script actions and save restores alike reach the same function. Its callers are both LoadJournal overloads that replay a save’s PARTYTABLE journal state (CSWSCreature::LoadJournal 0x004f17d0, CSWPartyTable::LoadJournal 0x00563430), the two AddJournalEntry sites, SetInt, and the AddJournalQuestEntry script command. |
| Hardcoded resref | The journal GFF it opens is constructed from the literal Global. This is what makes module-scoped files inert. |
| Reads live with the write | Field-level reading happens inside this same function rather than in a separate load pass, so the field table above and the state machine are one thing. |
| Consumers | CSWGuiInGameJournal::OnControlEntered (0x00645100) is where PlanetID’s sentinel test and the planetary.2da lookup live. |
Implemented Linter Rules (Rakata-Lint)
None yet. A module-scoped .jrl is the obvious candidate, since it is both detectable and always wrong, but no rule currently reads this format.