Skip to content

A new project that is named after your game - #88

Merged
greenfire27 merged 3 commits into
developmentfrom
new-project-improvements
Aug 18, 2026
Merged

A new project that is named after your game#88
greenfire27 merged 3 commits into
developmentfrom
new-project-improvements

Conversation

@greenfire27

Copy link
Copy Markdown
Collaborator

Every project this editor has ever made had a module called BlankGame in it. NewProjectDialog handed CopyModule the same id for source and target, so the engine's rename path never ran: the game module kept the template's name, an empty Author, and the template's description — "A blank game, ready for you to craft into something amazing!" — no matter what was typed into the dialog.

Fixing that turned up two deeper bugs, one of them a data bug that has affected every project ever created on Linux. Three commits, in the order they were found.


1. A new project that is named after your game

The dialog asks for six things now — Title, Directory, Game Core, Module Name, Author and Description — each with a tooltip on the caption as well as the input.

  • Module Name defaults to the title stripped to identifier characters plus Game (Space RangersSpaceRangersGame) and follows the title until you type your own, after which it's yours.
  • Game Core is a dropdown of the library templates a project can be built from.
  • Description now reaches the game module as well as AppCore, and Author reaches it at all.

Renaming a module is not renaming its ModuleId. The engine calls <ModuleId>::<CreateFunction>, so the id in module.taml and the namespace in the module's script are the same name written twice, and asset ids are that name a third time — <ModuleId>:<assetName>, in script and in every taml that references an asset. Rewrite only module.taml and the module still loads, still reports its new name everywhere the UI looks, and silently does nothing.

So the rename is a pass over the module's own source, in one place — ModuleStamper — called from all three paths that rename a module. The other two were worse than the New Project one because they can be reached repeatedly: New Module from a template and Edit Module changing a name both rewrote ModuleID and stopped there. The substitution is whole-word, because in the Edit Module case the old id is whatever someone called their module and can be a substring of an ordinary word.

The engine has half of this already and it isn't enough: copyModule's TamlModuleIdUpdateVisitor is root-only so a nested asset id is missed, it can't touch .cs at all, and it renames module.taml to <ModuleId>.module.taml — a name nothing else in the editor opens a definition by, with no fileRename bound to script to put it back.

Template is now a flag and Type says what a module is. Type == "Template" was only ever read as an enumeration filter; "leave this module alone" is enforced by the engine's Synchronized field and always was. So BlankGame is Type="Game Core", ArtPack is Type="Art Pack", both carrying Template="1" and a DisplayName.

2. A declared path spelled the way it was written

A project's game module declared Path="Sprites" and Path="Fonts" where the template said sprites and fonts. On Windows nobody notices. On Linux those directories don't exist, so an image dropped into sprites/ afterwards is never scanned and never becomes an asset — silently, with the module.taml sitting there looking correct. AppCore got it too, so this affected every project this editor has made.

Nothing in the path code was wrong. The fields were TypeString, which interns without caseSens, and StringTable's hash is case-insensitive by constructionhashString runs every byte through a to-lower table, so two spellings differing only in case always land in the same bucket, and an insensitive insert returns the first one there rather than the one it was asked for.

TypeCaseString already existed and already does the right thing, so this is a field-type change rather than new machinery.

Deliberately left folded: ModuleId, Group, Type and the create/destroy function names. Those are identifiers, they are compared as string-table pointers — ModuleManager does it in nine places for load order, groups and dependency resolution — and that matching has always been case-insensitive, so a project depending on AppCore=1 resolves against a module spelling itself appCore today. A test asserts ModuleId still folds, so it reads as a decision.

3. The folder a scan names is the folder you can open

BlankGame declared eight asset paths and shipped three, so every new project warned four times on first open about directories the template never had. Adding them — each with a readme saying what belongs there — is the content change. Adding them turned up the rest.

A directory called fonts came back out of readdir as "Fonts". Not sometimes: always, on this platform. Two of the colliding spellings are interned during static initialisation, before any engine code runs — SpriteBatch interns "Sprites" as a taml node name and guiProfileTheme interns "Fonts" as a field group. So sprites and fonts, the two most ordinary names an asset folder can have, were the two that could never survive a scan. That's why directoryDelete couldn't recurse into a folder it had just been told about.

The platform layer now interns what readdir gives it case-sensitively — and so do ResManager::getPaths, the zip and openFileForWrite paths, and ResDictionary's own path normalisation. Those had to move together: the dictionary derives its bucket from the pointer values of path and name and compares by pointer, so a half-applied change doesn't merely compare false, it hashes into the wrong bucket and reports an existing file as missing.

The four editor themes are part of this rather than incidental. Each asked for ^EditorCore/Themes/<name>/Fonts while the directory on disk is fonts, and that's where $GUI::fontCacheDirectory comes from. It worked only because the fold rescued it — guiProfiles.cs asks for the same LabCoat directory in lower case two files away, which is the codebase disagreeing with itself. Left alone they'd have become a real missing-font-cache bug the moment lookups stopped folding.


Testing

322 unit tests, 0 failures. 50 of 50 TorqueScript smoke suites.

New coverage:

Suite What it holds down
stringTableCaseTests the primitive in both directions, including the hazard the fix introduces — once a bucket holds one spelling insensitively and another sensitively, an insensitive insert can miss the sensitive entry
declaredPathCaseTests goes through setDataField, the route TAML takes. Carries a control: ModuleId is poisoned and set identically and still folds, which is what makes the six passing cases evidence about the field type rather than about the fixture
directoryScanCaseTests writes real sprites/readme.md and fonts/readme.md, plants the capitalised spellings by hand so the collision can't be absent by luck, and finishes by deleting the tree the scan just enumerated — the exact step that failed
tests/smoke/newProject.cs drives the dialog and reads the artifact off disk, case-sensitively; asserts every control ends inside the content pane
tests/smoke/moduleRename.cs both Project Manager rename paths

Both smoke suites end by loading the renamed module and asserting its gui reached the Canvas, because that is the only thing that proves create actually fired.

Also verified by hand: two projects built through the real UI, both fully renamed, grep -ril blankgame empty across the tree.

Notes for review

  • Only Linux is changed in the platform layer. The same intern sites exist in all six back ends and are wrong the same way, but Windows and macOS are case-insensitive at the filesystem so it costs them nothing today, and I could not run their tests. Worth mirroring by someone who can.
  • x86UNIXFileio.cc, resourceManager.cc and resourceDictionary.cc are CRLF; the diffs preserve that.
  • Rewritten .cs files come out CRLF, because FileObject::writeLine always emits \r\n. Harmless for the lexer and TinyXML, but it shows in a diff.

🤖 Generated with Claude Code

greenfire27 and others added 3 commits August 17, 2026 21:14
Every project this editor has ever made had a module called BlankGame in it.
NewProjectDialog handed CopyModule the same id for source and target, so the
engine's rename path never ran, and the game module kept the template's name,
the template's Author of nothing, and the template's description -- "A blank
game, ready for you to craft into something amazing!" -- no matter what the
person creating it typed. The dialog asked for three things and only the title
reached anything.

It asks for six now: Title, Directory, Game Core, Module Name, Author and
Description, each with a tooltip on the caption as well as on the input. Module
Name defaults to the title stripped to identifier characters plus "Game", and it
follows the title until the moment someone types their own, after which it is
theirs. Description now reaches the game module as well as AppCore, and Author
reaches it at all.

RENAMING A MODULE IS NOT RENAMING ITS ModuleId. The engine calls
<ModuleId>::<CreateFunction>, so the id in module.taml and the namespace in the
module's script are the same name written twice, and asset ids are that name a
third time -- "<ModuleId>:<assetName>", in script and in every taml that
references an asset. Rewrite only module.taml and the module still loads, still
reports its new name everywhere the UI looks, and silently does nothing: create
never fires, and its assets resolve to a module that no longer exists. Nothing
about the definition it wrote looked wrong, which is why this went unnoticed.

So the rename is a pass over the module's own source, and it lives in one place,
ModuleStamper, called from all three paths that rename a module. The other two
were worse than the New Project one, because a person could reach them
repeatedly: New Module from a template (ProjectGamePanel::onModuleCreated) and
Edit Module changing a name (::onModuleEdited) both rewrote ModuleID and stopped
there. The substitution is whole-word -- a match counts only where neither
neighbour can be part of an identifier -- because in the Edit Module case the old
id is whatever a person called their module, and can be a substring of an
ordinary word in a comment.

The engine has half of this already and it is not enough. copyModule runs a
TamlModuleIdUpdateVisitor when the ids differ, but that visitor is root-only, so
an asset id on a nested element is missed; it cannot touch .cs at all, which is
where the namespace lives; and it renames module.taml to <ModuleId>.module.taml,
a name that nothing else in the editor opens a module definition by, with no
fileRename bound to script to put it back. Hence the copy going out under the
template's own id and the rename happening afterwards, on the copy.

TEMPLATE IS NOW A FLAG AND Type SAYS WHAT A MODULE IS. Type == "Template" was
only ever read as an enumeration filter, in the two places that either wanted
templates or did not; "leave this module alone" is enforced by the engine's own
Synchronized field and always was. So BlankGame is Type="Game Core" and ArtPack
is Type="Art Pack", both carrying Template="1" and a DisplayName, the two filters
test the flag, and the Game Core dropdown can ask findModuleTypes for exactly the
templates a project can be built out of. The markers come off the copy.

Two fixes to what was already there, both found by the tests. The New Project
dialog appended a trailing backslash to its project path and then built every
other path on top of it, which the engine's own file calls expand away and
script-side isDirectory does not -- it stats the string it is given, so
ModuleStamper was handed a folder that did not exist. And the buttons were
positioned from the dialog's own height rather than from EditorDialog::
contentHeight, putting the Create button eight pixels past the fold and the whole
form behind a scroll bar; the comment on contentHeight names this exact trap and
lists two other dialogs that shipped with it.

Both suites end by loading the renamed module and checking its gui reached the
Canvas, because that is the only thing that proves create actually fired. The
layout checks assert every control ends inside the content pane, and they fail on
the code that shipped a scroll bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Make a project and its game module declares Path="Sprites" and Path="Fonts",
where the template it was copied from said "sprites" and "fonts". On Windows
nobody notices. On Linux those directories do not exist, so an image dropped into
sprites/ afterwards is never scanned and never becomes an asset -- silently, with
the module.taml sitting right there looking correct. AppCore gets it too, so
every project made by this editor has had it.

Nothing in the path code was wrong. The fields were TypeString, whose
ConsoleSetType interns without caseSens, and StringTable's hash is case
insensitive by construction: hashString runs every byte through a to-lower table,
so two spellings that differ only in case ALWAYS land in the same bucket, and an
insensitive insert then matches the first one there with dStricmp and hands back
ITS spelling rather than the one it was asked for. Whether a project came out
right depended on whether anything else in the process had already interned
"Sprites" -- which is why this looked intermittent and why the template on disk
was never wrong.

TypeCaseString already existed and already does the right thing -- insert(argv[0],
true) -- and is already used on taml serialized classes (GuiControl::text), so
this is a field type change rather than new machinery. Declared and Referenced
assets take it for Path and Extension; ModuleMergeDefinition for MergePath.
ModuleDefinition's ScriptFile and AssetTagsManifest are protected fields, so the
setter is the write path and takes the flag directly. The dead C++ setters beside
them were changed too, so the classes cannot disagree with themselves later.

WHAT IS DELIBERATELY LEFT FOLDED. ModuleId, Group, Type and the create and
destroy function names stay TypeString. Those are identifiers rather than paths,
and unlike the paths they ARE compared as string table pointers -- ModuleManager
does it in nine places for load order, groups, types and dependency resolution.
That matching has always been case insensitive, so a project depending on
"AppCore=1" resolves against a module whose definition says "appCore" today.
Making them case sensitive would break that, and it would present as a missing
module rather than as a spelling problem. There is a test asserting ModuleId
still folds, so this reads as a decision.

Every field changed here was checked for pointer comparison first, because that
is the way this fix bites back: StringTableEntry equality is pointer equality,
and it only holds because both sides were interned the same way. Once a bucket
holds one spelling insensitively and another sensitively, an insensitive insert
returns whichever node sits earlier in the chain, so a case sensitive field
compared against a value interned the old way can miss. None of these seven are
compared -- getPath only ever feeds a dSprintf, getExtension is matched with
dStricmp inside the scan, and ScriptFile, AssetTagsManifest and MergePath are
only ever tested against EmptyString before being formatted into a path.

The ResourceManager and TextureManager dictionaries are the reason the fix stops
here. Both HASH by pointer value, so a mismatch does not merely fail a
comparison, it never reaches the right bucket at all -- and their keys come from
the platform layer's dumpPath, which interns whatever readdir returned. That
cluster is self consistent today and has to move as one piece with every
platform back end. Worth doing, not worth doing halfway.

stringTableCaseTests pins the primitive down in both directions, including the
hazard above, so that a later simplification of the flag fails a test that says
why it exists. declaredPathCaseTests goes through setDataField rather than the
C++ setters, because that is the route TAML takes, and it carries a control:
ModuleId is poisoned and set identically and still folds, which is what makes the
six passing cases evidence about the field type rather than about the fixture.
The New Project suite checks the artifact -- Path="sprites", ScriptFile="game.cs"
-- case sensitively, against a project the editor really built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BlankGame declares eight asset paths and shipped three of them, so every project
made from it warned four times the first time it was opened -- about sprites,
fonts and particles, directories the template never had. Adding them is the whole
of the content change here, and each one carries a readme saying what belongs in
it and how a file there becomes an asset, because a template is something a
person opens and reads.

Adding them turned up the rest of this commit.

A directory called fonts came back out of readdir as "Fonts". Not sometimes:
always, on this platform, since the string table's hash is case insensitive by
construction and an ordinary insert returns whichever spelling of a name reached
the table first. Two of them get there during STATIC INITIALISATION, before any
of the engine runs -- SpriteBatch interns "Sprites" as a taml node name and
guiProfileTheme interns "Fonts" as a field group -- so sprites and fonts, the two
most ordinary names an asset folder can have, were the two that could never
survive a scan. That is why a copied module.taml said Path="Sprites", and why
directoryDelete could not recurse into a folder it had just been told about: it
was handed a name nothing could stat.

So the platform layer interns what readdir gives it case sensitively, and so do
ResManager::getPaths, the zip and openFileForWrite paths, and the ResDictionary's
own path normalisation. THOSE HAD TO MOVE TOGETHER. The dictionary derives its
bucket from the POINTER VALUES of path and name and compares them by pointer, so
a half-applied change does not merely compare false -- it hashes into the wrong
bucket and reports a file that exists as missing. The four in resourceDictionary
are the path key, the rest are the file key, and either one alone would have been
worse than the bug.

The four editor themes are part of this rather than incidental to it. Each asked
for "^EditorCore/Themes/<name>/Fonts" while the directory on disk is fonts, and
that is where $GUI::fontCacheDirectory comes from. It worked only because the
fold rescued it -- guiProfiles.cs asks for the same LabCoat directory in lower
case two files away, which is the codebase disagreeing with itself. Left alone,
they would have become a real missing-font-cache bug the moment lookups stopped
folding, so a latent bug and its disguise are removed in one go.

WHAT IS STILL DELIBERATELY FOLDED is unchanged from the previous commit: ModuleId,
Group, Type and the create and destroy function names are identifiers, they are
compared as string table pointers in nine places in ModuleManager, and dependency
resolution has always matched them regardless of case.

directoryScanCaseTests writes a real sprites/readme.md and fonts/readme.md, puts
the capitalised spellings in the table by hand so the collision cannot be absent
by luck, and then asks for the one thing all of this is about: that a caller can
open what the scan named. Its last two lines delete the tree the scan just
enumerated, which is the exact step that failed in the wild.

Only Linux is changed. The same intern sites exist in all six platform back ends
and are wrong in the same way, but Windows and macOS are case insensitive at the
filesystem, so it costs them nothing today and I cannot run their tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greenfire27
greenfire27 merged commit ad80a00 into development Aug 18, 2026
18 checks passed
@greenfire27
greenfire27 deleted the new-project-improvements branch August 18, 2026 04:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant