Skip to content

Modernize and organize project to current .NET standards, make cross-platform, fix various bugs, and close functionality gap#5

Open
gtkramer wants to merge 89 commits into
NCDyson:masterfrom
gtkramer:master
Open

Modernize and organize project to current .NET standards, make cross-platform, fix various bugs, and close functionality gap#5
gtkramer wants to merge 89 commits into
NCDyson:masterfrom
gtkramer:master

Conversation

@gtkramer

@gtkramer gtkramer commented May 30, 2026

Copy link
Copy Markdown

Let me know if you want to change anything. This incorporates changes from @Casuallynoted, @taarna23, as well as my own. It fixes #3 and #4, wires up bounding box logic, gets the project to run natively cross-platform with modern .NET, and aligns repo artifacts and organization to current .NET standards.

gtkramer and others added 30 commits May 29, 2026 02:32
Modernize the project and improve cross-platform compatibility
Have the camera move more closely with the mouse
Loading previously ran the whole CCSFile read+init inside the render
callback (on the UI thread), so parsing a large file froze the UI and
stalled the animation. Split loading into a CPU-only parse phase that
runs on a background Task and a GL-upload phase that stays on the render
callback (the only place the GL context is current):

- Scene.LoadCCSFile -> ReadCCSFile (parse, any thread) + InitCCSFile (GL).
- EnqueueGlJob is now callable from any thread; it marshals the
  RequestNextFrameRendering wake-up to the UI thread.
- MainWindow.LoadFiles parses on Task.Run, enqueues the GL init, then
  adds the tree node via the dispatcher; read failures are caught/logged.

Because logging now happens off the UI thread, guard Logger's de-dup
dictionaries with a lock and restructure AppendLog to marshal to the UI
thread before echoing, so each message is written exactly once.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The windows were ported WinForms-style: each declared private fields
mirroring its controls, assigned them via this.FindControl<T>("name") in
the constructor, and defined a manual InitializeComponent that called
AvaloniaXamlLoader.Load. Avalonia's XAML compiler already generates
strongly-typed fields for every x:Name'd control plus InitializeComponent,
so all of that was redundant boilerplate with only runtime-checked names.

Drop the manual InitializeComponent methods and the ~36 FindControl
lookups across the four windows and reference the generated fields
directly. This also removes the AvGrid alias in MainWindow (the generated
fields are already typed, so the StudioCCS.Grid vs Avalonia.Controls.Grid
ambiguity no longer surfaces). No behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The main window populated its TreeViews by recursively constructing
TreeViewItems in code (BuildTreeItem/BuildSceneAnimeItem) and drove the
View-menu toggles + status bar through imperative handlers
(ApplyViewMenu, IsChecked lookups, a per-frame UpdateStatus).

Move this to idiomatic Avalonia:

- A light MainViewModel (ViewModelBase + INotifyPropertyChanged) exposes
  the tree data sources (CcsRoots / SceneRoots), the render-option toggles
  (which write straight through to the static Scene), and the status text.
  It's a thin shim over Scene, not a full MVVM layer.
- The TreeViews bind ItemsSource to those collections and render via a
  shared compiled-binding TreeDataTemplate over CcsTreeNode; per-node-type
  context menus move to ContextRequested handlers (CcsTreeNode lives in the
  portable model and shouldn't carry UI command info).
- View-menu items two-way bind IsChecked to the view-model; the status bar
  binds its text. A timer just refreshes the camera string.
- CcsTreeNode.Nodes becomes an ObservableCollection so the scene tree
  updates when animations are added/removed after binding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
StudioCCS.Grid (the static OpenGL grid-renderer helper) collided with
Avalonia.Controls.Grid, which had forced a 'using AvGrid = ...' alias in
the window code-behind. Rename the class (and its file) to GridRenderer so
the name no longer shadows the Avalonia control. Only the three call sites
in Scene.cs reference it; the "Grid" shader-file name and log strings are
unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bring the bone editor in line with the rest of the UI and remove
duplication that had accumulated across the windows:

- EditBoneWindow now binds its TreeView to CcsTreeNodes (Tag = the bone)
  via ItemsSource instead of hand-building TreeViewItems, matching the CCS
  and scene trees. The BoneNodeTag wrapper is gone (the CCSObject is the
  Tag directly).
- The CcsTreeNode TreeDataTemplate moves to App-level resources so all
  three trees share one definition rather than redeclaring it.
- File-picker type descriptors (All / CCS / Bin) are centralized in a
  FileFilters helper, replacing three inline copies across the load,
  load-matrix, and pose dialogs.
- The two ContextRequested handlers share helpers (ContextNode, Menu,
  OpenNodeMenu) so node extraction, menu construction (single IList cast),
  and pointer-placed opening are written once; pattern matching is
  standardized on 'is not'.

No behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Preview/Scene/All toggle was three ToggleButtons whose mutual
exclusion was hand-maintained with a _suppressModeEvents reentrancy guard
and manual IsChecked juggling in code-behind — the one imperative holdout
in an otherwise VM-bound UI.

Replace them with grouped RadioButtons (mutually exclusive by design)
bound to a new MainViewModel.Mode property (with Is*Mode bool wrappers for
the bindings). Mode is the single source of truth and writes through to
Scene.SceneDisplay. Code-behind now only mirrors mode changes into the
panel layout (kept there because the column GridLength collapse doesn't
bind cleanly), via the view-model's PropertyChanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scrolling up now zooms the camera in (toward the model) rather than out,
which matches the common convention. Negated the wheel delta forwarded
from the viewport to Scene.MouseWheel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Clicking a tree item's label now expands/collapses it, so users don't
have to hit the small expand/collapse chevron. A shared TreeViewExpand
helper handles the TreeView's Tapped event: it ignores taps on the
chevron (which already toggles) and otherwise toggles IsExpanded on the
tapped row if it has children. Wired to all three trees (CCS objects,
scene animations, clump bones).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The orbit camera reconstructs its orientation with Matrix4.LookAt and a
fixed +Y up-vector. At exactly +/-90 deg pitch the view direction aligns
with that up-vector, the camera basis (cross(forward, up)) collapses, and
the model and axis gizmo flip 180 deg (gimbal lock at the pole).

Clamp pitch to +/-89.9 deg so the turntable never reaches the singular
orientation: rotation stays smooth and level right up to a near-top-down
view. 89.9 keeps an effectively straight-down view while staying well
clear of float precision issues (cos(89.9 deg) ~= 0.0017).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Shaders (data/shaders/*.{vsh,fsh,gsh}) and the vertex blobs
(data/bin/*.bin) are now compiled in as AvaloniaResource items and read
at runtime through avares:// URIs via a new EmbeddedData helper, instead
of opening FileStream/StreamReader against files copied to the output
directory. This drops the data/**/* copy-to-output rule; only
blenderDummyImport.py is still copied out.

Since the data is now immutable and assembly-resident, the disk
reload-from-file paths no longer make sense: removed the unused
AxisMarker.Reload() and demoted WireHelper.ReadBin() to private (it has
only the single internal Init() caller).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only the GL bindings and math types are used; Avalonia provides the
window and GL context. Dropping the OpenTK metapackage removes the
unused GLFW windowing native redist, audio, compute, and input packages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old static Logger owned plumbing the framework already provides and
had several design flaws: severity was encoded as a System.Drawing.Color
(so the core had no log levels and couldn't filter), dedup keyed on
string.GetHashCode() (collisions could silently drop distinct messages),
the LogOnceCode half was dead, and caller info was captured then discarded.

Adopt Microsoft.Extensions.Logging via a thin static facade (Log.Error/
Warning/Info) over a LoggerFactory configured once at startup. This matches
the codebase's existing static-utility idiom (no DI container) while letting
the framework own levels, filtering, formatting, and the stdout sink.

What we still own is just the two things we actually care about:
- PanelLoggerProvider: routes formatted output to the in-app log panel and
  maps LogLevel -> Color in the view layer (the only place Color now lives).
- LogOnce: per-frame flood protection, keyed on the message string (fixes
  the GetHashCode collision risk). Replaces the old LogType machinery; the
  4 render-loop call sites now pass once: true.

Log.* also trims trailing newlines centrally, so each sink owns line
termination (the framework console provider and the panel provider each
append one) instead of every call site baking in "\n".

Verified: app loads CCS files, GL context initializes, and info/warn/fail
all emit through the console provider with correct level prefixes and no
double-newlines (0 across 3526 log lines over a 30-file load).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-app log panel was a plain read-only TextBox: it ignored the colour
the provider passed and showed no severity, so it read as flat, level-less
text while stdout (via the console provider) was clearly tiered. Make the
panel a copy of stdout.

- PanelLoggerProvider now prefixes each line with the console's level
  abbreviation ("info:/warn:/fail:" etc.) and emits one line per log call
  (no trailing newline; the facade already trims). Severity colours are
  brightened to read on a dark surface.
- The panel becomes a virtualizing ListBox of colour-coded lines (LogLine:
  text + brush), keeping it responsive under heavy logging where the old
  Text += concat was O(n^2). Backing collection is capped at 2000 lines,
  oldest dropped first, and auto-scrolls to the newest.
- The panel background is fixed dark (not theme-dependent) so white/orange/
  red stay legible regardless of the OS light/dark setting.

Verified live: loaded several CCS files and confirmed the panel renders
orange "warn:" and grey "info:" lines on the dark console, mirroring stdout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SimpleConsole formatter printed "info: StudioCCS[0] message" — the
category/eventId is constant and redundant in a single-app process, and it
made stdout diverge from the in-app log panel ("info: message").

Replace SimpleConsole with a small custom ConsoleFormatter that prints just
"<level>: <message>", ANSI-colouring the level tag only on a real terminal
(suppressed when stdout is redirected). The level abbreviation now comes from
a shared LogLevelTag helper used by both the console formatter and the panel
provider, so the two renderings can't drift apart.

Verified: stdout shows "info:/warn:/fail: <message>" with no "StudioCCS[0]"
and no stray ANSI escapes when redirected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The console and panel each had their own severity-colour table (console:
ANSI yellow/green/red; panel: orange/grey/bright-red) and disagreed on scope
(console coloured just the level tag, panel coloured the whole line). So even
matching text rendered with different colours.

Introduce LogPalette as the single source of truth for severity colours (RGB).
The console formatter emits a 24-bit truecolor ANSI escape from it; the panel
builds its brush from the same values — so the two are identical by
construction. Both now colour only the severity tag: the panel template renders
the tag in its colour and ": <message>" in a neutral light grey, matching the
console (which resets colour before the message).

Verified: panel shows an orange "warn:" tag with a neutral message, and a pty
capture of stdout shows "\e[38;2;255;165;0mwarn\e[0m: ..." — same orange,
tag only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GL profile was requested only through X11PlatformOptions, so the
desktop OpenGL 3.3 (#version 330 + geometry shader) context the CCS
shaders require was set up on Linux alone. On Windows the app fell back
to ANGLE (OpenGL ES), which cannot compile those shaders.

Choose the profile by platform from one shared desktop profile list:
Windows switches to native WGL (RenderingMode=Wgl + WglProfiles), Linux
keeps X11 GlProfiles. macOS (AvaloniaNative) exposes no GL-version
selector, so it is documented as requiring on-device verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Encoding.Default is platform-dependent (Windows-1252 on Windows, UTF-8
elsewhere), so any name byte >= 0x80 decoded differently per OS. CCS
asset names are ASCII; using Encoding.ASCII makes parsing deterministic
across platforms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Store text files with LF in the repo while checking them out with each
platform's native ending (CRLF on Windows, LF elsewhere), keeping history
consistent without fighting local tooling. Mark known binary assets
(.dds/.png/.bin/.ccs) so they are never normalized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
texture2D was removed from GLSL in the 330 core profile; desktop Mesa
and NVIDIA/AMD drivers accept it leniently, but Apple's strict core
compiler rejects it, which would fail shader compilation on macOS.
Switch the three active samplers to the overloaded texture() call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verify the negotiated context is desktop GL 3.2+ in OnOpenGlInit; if it
is older, disable the viewport with a clear log message instead of
binding shaders that never compiled and spamming GL errors into a black
viewport every frame. The disabled state fills the viewport dark red and
stops requesting frames rather than spinning.

Also register a KHR_debug callback in debug builds (where the context is
4.3+) to surface driver warnings and errors through the existing log,
making platform-specific rendering issues diagnosable instead of silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The capability guard accepted 3.2, but the shaders are #version 330 and
need GL 3.3, so a 3.2 context would pass the guard and then fail every
shader compile - the same silent black viewport the guard exists to
prevent. Raise the floor to 3.3 and drop the unusable 3.2 entry from the
context negotiation list. 3.3 is available everywhere; macOS meets it
with its 4.1 core context (it offers only 3.2 or 4.1, never 3.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five shaders (CCSClump and Triangle) declared bare #version 330 while the
rest used #version 330 core. The two are equivalent on a core context
(330 defaults to core), so this is a consistency/intent change, not a
behavioral one: every shader now explicitly states it targets the core
profile and relies on no compatibility features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A SharpDevelop/Windows-era toolchain had saved 46 source, shader, and doc
files as UTF-8 with a leading BOM (EF BB BF). The marker is redundant for
UTF-8, breaks naive first-line parsing (it hid shader #version lines from
grep), and is out of place in a cross-platform repo. Remove the three BOM
bytes from every affected file - content and LF line endings are otherwise
untouched.

Add a root .editorconfig pinning charset = utf-8 (no BOM) so Windows
editors do not silently reintroduce it, complementing the existing
.gitattributes line-ending normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ask the WGL/X11 backends for OpenGL 4.6 (the highest version) first so
capable Windows/Linux drivers also grant KHR_debug (core in 4.3), which
lets the debug-build output callback actually engage. The list still
falls back to 3.3 - the minimum the #version 330 shaders require - when a
driver cannot provide anything newer. macOS is unaffected (AvaloniaNative
ignores this list and caps at a 4.1 core context).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the render-info readout out of the full-width bottom strip and into
the viewport column: render mode in a top bar, live camera state in a
bottom bar, so the GL surface is sandwiched between them and the tree
spans the full side. The mode bar gets the full viewport width since the
list grows as more views are toggled on.

Also reformat the camera readout with grouped axis labels, degree marks,
and fixed-width fields rendered in a monospace font so it stays legible
and stops jittering as it refreshes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Fluent-themed controls already tracked the OS light/dark setting, but
the GL viewport (clear colour + grid lines, set in raw GL) and the Mode/
Camera status bars (hardcoded #202020) stayed dark regardless, which looked
out of place against a light UI.

Drive the viewport clear/grid colours from Scene.BackgroundColor/GridColor,
re-applied every frame and updated on ActualThemeVariantChanged so an OS
theme switch is reflected live. Move the status-bar palette into theme-keyed
ResourceDictionaries (DynamicResource) so the bars re-resolve on a switch.
The dark values are unchanged; only a light-mode variant is added. The log
panel stays fixed-dark by design (its severity colours need a dark ground).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gtkramer and others added 6 commits June 5, 2026 01:03
With thousands of log lines, the log ListBox's VirtualizingStackPanel throws
"Invalid Arrange rectangle" from ScrollIntoView's forced layout pass, aborting
the whole app at extreme file counts. It fired from two distinct triggers, so
fix both to close the class of crash:

- Our own auto-scroll to the newest line in FlushLogs runs back-to-back during a
  heavy parse; wrap it and swallow the transient InvalidOperationException. The
  scroll is cosmetic, so skipping one during extreme churn is harmless.
- The ListBox's built-in auto-scroll-to-selected runs ScrollIntoView during a
  layout reflow (e.g. switching to the All view) and is posted by the framework,
  so it can't be wrapped at our call site. Disable it with
  AutoScrollToSelectedItem="False"; we already scroll to the newest line
  ourselves, and selecting/copying lines still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three stray Console.WriteLine("Wtf 1") calls sat in CCSHitMesh.RenderAll, so
they printed every frame for every hit group — flooding stdout and adding
needless console I/O on the render thread once any hit-mesh is on screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Preview/Scene/All radio group sat on its own row below the menu, leaving a
wide empty band beside the menu. Put both in a top DockPanel: the radio group
docks Right (anchored to the window edge) and the Menu fills the rest of the row.
A left margin on the radio group keeps a minimum gap from the menu items. This
reclaims the wasted space and gives the viewport a little more height.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CCSFile.Init allocates GL resources for the hit, clump, texture, and bbox
sections, but DeInit only freed the hit and bbox ones. So every unload leaked
all of a file's texture handles, its clumps' matrix buffers/textures and bone
VAOs/VBOs, and the ref-counted shared clump shader program (ProgramRefs never
reached zero, so the program was never deleted).

Mirror Init and DeInit the clumps and textures too. Their DeInit methods already
existed and are correct (the program is ref-counted and recreated on demand via
the ProgramID == -1 guard); they simply were never called. Fixes the leak for
both the per-file Unload and the upcoming Unload All.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Right-clicking a loaded file in the tree now offers "Unload All" alongside the
per-file "Unload", clearing every loaded CCS file without restarting the program.

UnloadAllCCSFiles DeInits each file (freeing its GL resources), clears the file
list, and drops the scene state that points back into the files - the active
animations and the preview selection - so nothing dereferences freed objects
afterwards. It runs as a single render-thread job (GL context current), and the
bound trees are cleared on the UI thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bottom camera readout was one monospace string, padded with spaces to keep
it from jittering as it refreshes ~10x/sec. Replace that with a proper layout:
split CameraStatus into per-value properties (rotation, target, distance) and
place each in a fixed-width, right-aligned box so the columns stay put without a
monospace font.

Labels and values now use the normal face in the dark value colour at SemiBold,
matching the top Mode bar. The gap after "Camera" matches the Mode bar's, and the
Rot/Target/Dist groups are separated by margin (wider than a value box, so each
group's last value reads as part of its group rather than the next label).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gtkramer

gtkramer commented Jun 5, 2026

Copy link
Copy Markdown
Author

I took this a bit further and loaded all 1023 CCS files from .hack//INFECTION and the program no longer crashes, it remains responsive, and it does so pretty quickly.

Though, I'm not in love with the logging panel. It seems to be causing problems with the current implementation. This might need to be redone.

@gtkramer

gtkramer commented Jun 5, 2026

Copy link
Copy Markdown
Author

Regarding the drag and drop bits being broken. It's not a problem in our code, rather, a problem in Avalonia on Linux:

If this stays on track, it's targeted for Avalonia 12.1. This should work just fine on Windows, but I'll need to test.

gtkramer and others added 4 commits June 5, 2026 02:08
A thin bar docked at the bottom of the window, shown only while a file-load
batch is in flight. It advances one step as each file finishes fully loading
(GL upload done, or parse failed/skipped), so it tracks real completion rather
than parse start. Counts are cumulative across overlapping batches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The render callback ran the entire queued-GL-job queue each frame and only then
called Scene.Render(); since the callback runs on the UI thread, a large load
batch blocked it for seconds at a stretch. Spend at most ~8ms on jobs per frame
and let the continuous redraw loop drain the rest on following frames. At least
one job always runs per frame to guarantee forward progress.

Impact beyond UI chrome staying responsive:
- The 3D viewport keeps rendering during a load. Previously Scene.Render() did
  not run until the whole queue cleared, so the viewport froze - no camera
  orbit, animation playback, or grid redraw - until the load finished. Now it
  stays live throughout.
- Progressive visual feedback: frames render between job batches, so models pop
  into the scene as they finish uploading instead of all appearing at the end.
- Bounded UI-thread occupancy for everything competing during a heavy load
  (menus, resize, the log panel, theme changes), not just the load itself.

This helps work that is split into many jobs (one per file on load). It does not
subdivide a single monolithic job, so the unload paths - enqueued as one job -
are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A four-digit value plus ".x" overflows the fixed-width status boxes, so
every numeric value in the camera bar now keeps one decimal until its
magnitude hits 1000 and drops it after. Shared via FormatCameraValue so
the rotations, targets, and distance all behave identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wheel notch added a fixed distance, so it moved the view ~1% at
distance 10 but only ~0.1% at distance 100 - far-away zoom crawled.
Scaling the step by Distance/Reference turns the constant additive step
into a constant multiplicative one, so each notch is the same percentage
change at any zoom level. Applied to both the wheel and keyboard zoom.

WASD/XZ panning had the same problem - a fixed target step is a tiny
fraction of the view when zoomed out, so panning crawled. It now scales
by the same distance factor, so a pan moves a constant fraction of the
view at any zoom level. The shared anchor is CameraDistanceReference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gtkramer and others added 7 commits June 5, 2026 02:47
Centralize the default framing in ArcBallCamera.Reset() so construction
and the reset action share one definition and can't drift apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the R-key camera reset, the three viewport modes (Preview/Scene/
All), the View-menu display toggles, and the Default to Axis Movement option.
Correct stale claims surfaced by cross-referencing the code: bounding boxes
and dummies now render in All mode, clicking an animation previews its first
frame, the export menu item is "Dump to .OBJ...", and Load Matrix loads a
per-bone 4x4 matrix dump. Also fix the companion script path and an ambiguous
Unicode character.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DeInit looped over every node calling DeInit() unconditionally, but
GetObject<CCSObject> returns null for non-object nodes (most commonly
effect nodes), causing a NullReferenceException when unloading files
that contain such clumps. This surfaced when unloading the full asset
set after loading all 1023 assets.

Rework the loop to mirror Init: branch on node type and DeInit both
SECTION_OBJECT and SECTION_EFFECT nodes via their correct typed
lookups, each null-guarded. Behavior-neutral today (CCSEffect.DeInit
is a no-op) but restores the Init/DeInit symmetry the teardown
contract relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shader-uniform guard in CCSClump.Init checked UniformMatrix twice
and never validated UniformMatrixList, so a missing uMatrixList uniform
would slip through unreported. Check the correct location and fix the
log label to match the value it prints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CCSClump.Init created the static shader program, then fetched its
attribute/uniform locations, and bumped the shared ProgramRefs counter
only afterwards. If an attribute or uniform lookup failed in between,
Init returned with ProgramID left pointing at a live program that was
never ref-counted - leaking it and desyncing the static accounting from
the set of clumps actually holding the program.

Delete the program and reset ProgramID to -1 on those failure paths, so
the invariant holds: ProgramID is set only when the program was fully
acquired, and every clump that increments ProgramRefs holds a valid,
matching program.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Init allocates incrementally - shader program ref, BoneVis VAO/VBO,
matrix buffer and texture, then Init's each child node - but its one
post-allocation failure path (a null effect node) just returned false.
Because a file whose Init fails is discarded without ever being added to
the scene's file list, DeInit never runs on it, so every GL object this
clump (and any fully-init'd sibling clump in the same file) had already
allocated was leaked.

Extract the teardown DeInit was doing into DeInitNode/ReleaseClumpResources
helpers and call them on the failure path to roll back this clump's own GL
resources, release its ProgramRefs hold, and DeInit the nodes that were
actually initialized (indices before the failure). Nodes at/after the
failure were never Init'd and are deliberately left untouched, since
DeInit'ing them would corrupt CCSModel's own static ref counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the fixed value-box width from 40 to 44 and scale the Target/Dist
label left margins from 50 to 55 to preserve the 1.25 width-to-margin
relationship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gtkramer

gtkramer commented Jun 5, 2026

Copy link
Copy Markdown
Author

The last thing I want to do is rework the logging panel a bit, then we can do a review of this for merging.

I would like to add the ability to unpack data.bin files as well, so StudioCCS becomes a one-stop-shop for all things CCS, but that can be done in a separate PR.

gtkramer and others added 8 commits June 7, 2026 00:06
A heavy load (all 1023 CCS files from .hack//INFECTION) brought the log
panel's accumulated issues together: a garbled last line, no light/dark
theming, and a pair of crash workarounds.

The garbled last line and the earlier "Invalid Arrange rectangle" crash
were the same root cause - ScrollIntoView forcing a synchronous layout
pass through the virtualizing panel. The try/catch from 42c786a stopped
the crash but left the panel half-arranged, which is the text-on-text
corruption. So drop ScrollIntoView entirely and tail the newest line by
setting the ScrollViewer offset, which the panel resolves on its normal
layout pass - no forced reflow to crash or corrupt. A pinned-to-bottom
flag keeps the view glued to the tail unless the user scrolls up to read,
and re-evaluates only on a genuine offset move so an incoming batch
growing the extent isn't mistaken for the user scrolling away. Both hacks
are gone; AutoScrollToSelectedItem stays off as the correct UX for a log
rather than as a crash bandaid.

Theme the panel: the background reuses StatusBarBackground so it matches
the bars around the viewport, and the severity colours come from
theme-aware Log* resources (dark mirrors the old fixed look; light gets
darker text and deeper amber/red so info text and tags stay legible).
LogLine now carries the severity level and the tag colour is driven by
warn/error style classes, so a theme switch recolours every line live and
no brush is marshalled in with the text.

Extract the buffer and the thread-safe, coalesced hand-off into a new
LogConsoleModel, so all of the panel's threading lives in one place
instead of inline in the window. It uses AvaloniaList so each burst is one
range update rather than N CollectionChanged events plus N RemoveAt(0)
shifts, and splits interior newlines so every row stays single-line
(uniform height, which the virtualization and tail math both rely on). The
System.Drawing.Color plumbing and the view's brush cache are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LoadMatrix, OnSavePose and OnLoadPose called into the binary parse/write
helpers directly after the file-picker await, so a malformed or unreadable
file threw on an async-void handler and crashed the app rather than logging
a failure. Wrap each in try/catch that reports via Log.Error, the same way
the main CCS load path already handles a bad file.

LoadMatrixList and LoadPose also read straight into the live FinalMatrixList /
pose / bind arrays as they parsed, so a truncated file threw partway through
and left the clump half-overwritten (and still rendered). Parse into a local
buffer and commit to the live arrays only after the whole file reads cleanly,
so a bad file now leaves the existing matrices/pose intact.
Util.NonExistantNode flags a missing object reference by setting the node's
ForeColor to red, but the shared CCSNodeTemplate only bound Text, so the
port silently dropped that cue and broken references rendered like any other
node. Surface it via an IsMissing flag on CCSTreeNode that toggles a
missingNode style class, coloured from a new theme-aware TreeNodeMissing
brush - the same class-driven, theme-tracking pattern the log panel uses,
so normal nodes keep the default foreground and only flagged ones turn red.
- LogPalette: correct the doc comment, which claimed the log panel reuses
  these RGB values; the panel actually has its own theme-aware Log* brushes
  (App.axaml) and LogPalette is console-only.
- App.axaml: drop the StatusBarMono brushes (both themes), referenced nowhere.
- MainWindow.axaml: drop the unused viewportHost x:Name.
- EditBoneWindow: remove a leftover Debug.WriteLine (and its now-unused
  using), and replace two ad-hoc deg<->rad constants (0.0174533f, 3.14159265f)
  with OpenTK's MathHelper.DegreesToRadians/RadiansToDegrees.
The three .OBJ/.SMD export handlers called Scene.Dump* directly after the
dialog await, so any file-I/O failure (bad path, permission denied, disk
full) surfaced as an unhandled exception on an async-void handler and took
the whole app down. Route all three through a RunExport helper that catches
and logs the failure, matching the care the file-load path already takes.

Kept synchronous on the UI thread on purpose: the dump mutates shared scene
state (each clump's FrameForward advances/recomputes its pose) that the
render loop reads every frame, so it must stay serialized with rendering
rather than race it on a background thread.
The GL viewport re-armed itself unconditionally every frame, so it drew at
the display refresh rate forever - pinning a core/GPU even on a static, idle
view. Replace that with an on-demand model:

- Scene gains a RequestRedraw() signal (a static RedrawRequested event) that
  GlViewport subscribes to over its visual-tree lifetime, plus a
  WantsContinuousRender() predicate (a held camera key or a playing
  animation). The render callback now re-arms only while GL jobs remain or
  WantsContinuousRender() is true, and otherwise idles until woken.
- Triggers live at the mutation sites: the camera input methods and
  AddAnime/RemoveAnime raise RequestRedraw directly; discrete UI changes
  (render-option/mode toggles via the VM, tree selection, theme switch, the
  Set Pose / Load Matrix actions, and every bone-editor edit) raise it from
  their handlers. GlViewport also wakes on EffectiveViewportChanged so a
  resize repaints.

This also lays the groundwork for suspending rendering (next commit): the
render callback now has a single, explicit decision point for whether to
draw and re-arm.

Two consequences of no longer drawing every frame are handled here too:

- DeltaTime is the wall-clock gap between Render() calls and scales camera
  motion/animation stepping, so a long idle would make the first frame after a
  wake lurch by the whole elapsed gap. Clamp it to a sane maximum so a wake
  steps once.
- A camera key is cleared only by its KeyUp, which a control receives only
  while focused; a key held while the viewport loses focus would otherwise keep
  WantsContinuousRender() true forever and never let the loop idle. Release all
  held keys on viewport LostFocus / window deactivation.

The predicate errs toward rendering, so a missed case is a wasted frame, not
a frozen viewport. Needs runtime verification across the interactions (camera
drag/keys, animation playback, option toggles, bone editing).
Building on render-on-demand: exports no longer freeze the app. RunExport runs
the dump on a background thread (Task.Run) while a modal BusyDialog is shown,
and guards the scene state the dump reads so nothing can mutate it mid-export.

The dump mutates shared scene state (each clump's FrameForward advances and
recomputes its pose) that the render loop reads every frame and the bone editor
writes, so both have to be kept off it for the duration:

- Scene.ExportInProgress is set while the dump runs. The render callback skips
  all scene work (Scene.Render and the GL-job drain) while it's set, so the
  render loop can't race the export's FrameForward mutations; it idles until the
  flag clears and a redraw is requested.
- The BusyDialog is modal over MainWindow, which blocks the menu/tree export and
  edit actions and gives feedback; the user can't dismiss it (no decorations,
  OnClosing cancels). But it does NOT disable EditBoneWindow, which is a separate,
  unowned top-level window - so the same ExportInProgress flag also makes the
  bone editor refuse edits, closing the one mutation path the modal misses.

The flag is raised and the dialog shown inside the try, so the finally always
clears the flag and closes the dialog even if showing it throws - otherwise a
failure there would leave the viewport suspended with no way to recover.

The export dialog's getters read its controls, so the chosen path/options are
snapshotted on the UI thread before the background dump; reading them inside the
Task.Run lambda would touch the controls from the wrong thread.

A bonus of off-threading: the dump's own progress logging now appears in the
log panel live instead of all at once after the freeze.
Exporting to OBJ with "Dump animations to text" segfaulted the whole app.
CCSAnime.DumpToText walks every controller across every frame and called
CCSClump.BindMatrixList() each time; DumpPreviewToSMD (debug-only) did the
same. BindMatrixList is a pure GPU upload (GL.BufferData of the matrix texture
buffer) - the text/SMD dumps never read it, they read the controllers'
CPU-side keyframe tracks, so the call contributed nothing to the output.

Since exports now run on a background thread (no current GL context), that
stray GL call faulted in the native driver - a SIGSEGV, not a managed
exception, so RunExport's try/catch could not catch it and the process died.
The base OBJ/SMD dump paths are already GL-free (FrameForward touches only
managed arrays); these two animation paths were the only ones still touching
GL off-thread.

Drop both calls. Output is unchanged and the dump no longer depends on a GL
context, and it skips a pile of needless per-frame uploads. The remaining
BindMatrixList callers (CCSClump.Render, CCSAnime.Render) run inside the
render callback where the context is current and are untouched.
@NCDyson

NCDyson commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Yeah, I was thinking about DATA.BIN packing and unpacking as a feature. The unpacking into a single directory is pretty easy. The tables in the elf files for them need to be changed if the file sizes change too much when repacking, but it wouldn't be too hard to scan the elf files to figure out where the tables are and patch them.

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.

Request: Unload All option

4 participants