Skip to content

feat: paint-list dynamic resize + mip-cap fix for heavy paint packs#5

Open
AdaInTheLab wants to merge 7 commits into
OCB7D2D:masterfrom
Kitsune-Den:feature/paint-limit-1023
Open

feat: paint-list dynamic resize + mip-cap fix for heavy paint packs#5
AdaInTheLab wants to merge 7 commits into
OCB7D2D:masterfrom
Kitsune-Den:feature/paint-limit-1023

Conversation

@AdaInTheLab

@AdaInTheLab AdaInTheLab commented Apr 5, 2026

Copy link
Copy Markdown

Summary

Updated 2026-05-28: cleaned up the original PR surface so the diff is just the technical changes. Fork-branding (LICENSE, README rewrite, ModInfo flip) and committed working-notes have been reverted out. The actual code changes below are net-additive for any OcbCustomTextures install.

What's in the diff

Harmony/OpaqueTextures.cs ~ additive safety nets for paint-list capacity

  • Save-reload protection: pre-size BlockTextureData.list to ≥1024 at config-load time, before chunk deserialization can access high paint IDs from saved data. Vanilla-sized configs don't observe a difference (later passes resize back down as needed).
  • BlockTextureData.Init safety net: auto-grows the list on demand if any code path tries to register a paint with ID >= list.Length. No-op for the common case where the array is already large enough.
  • GetFreePaintID() grow-instead-of-throw: was throw new Exception("No more free Paint IDs") when the array filled at 255. Now resizes by 256 and returns the next free slot. The only behavior change in vanilla terms ~ happy to revert to throwing if you prefer to keep that as an explicit failure mode rather than a transparent grow.
  • Dedicated server fallback: builtinOpaques == -1 (no GPU atlas) is now clamped to 0 instead of falling through to incorrect ID assignment on headless installs.
  • ApplyTextures Max() sizing: texture.depth + OpaquesAddedMath.Max(texture.depth, builtinOpaques) + OpaquesAdded. Returns the same value as the original when texture.depth >= builtinOpaques (the common case); properly sized when the texture allocation was smaller than the current builtinOpaques count.

Utils/OcbTextureUtils.cs ~ mip-cap bug fix (independent of paint capacity)

PatchTexture looped 0..dst.mipmapCount regardless of how many mips src actually had. When src is smaller than dst (e.g. a 128px paint texture copied into a bigger atlas slot through here, which is how third-party paint packs ship), Unity logs ERR Graphics.CopyTexture called with invalid source mip level (got 8, have 8 mips) for every iteration past src's last. Caps the loop at Math.Min(dst.mipmapCount, src.mipmapCount - offset) and bails when src has nothing usable at the current quality offset.

This one is unrelated to PaintUnlocked and benefits any OCB install with mixed-size paint packs.

Compatibility profile

Every new branch in this PR fires only when something would have previously crashed, thrown, or log-spammed. Specifically:

  • A save references paint IDs above the current array size ~ would have crashed
  • A pack registers paints exceeding current capacity ~ would have thrown "No more free Paint IDs"
  • The atlas needed more slots than the initial texture allocated ~ would have undersized
  • A paint texture is smaller than its destination atlas slot ~ would have spammed Unity errors

Users not hitting those conditions don't observe any difference.

End-state I'm still hoping for

The extension-points refactor we discussed in 5/12 is still my preferred outcome ~ a few of OCB's currently-inlined paint-list constants becoming named static readonly int fields, plus a public floor/offset for builtinOpaques that PaintUnlocked can set from its InitMod, would let me drop most of these Harmony patches entirely.

If it'd help, I'd happily break those out into separate small PRs once you've had a chance to look at this one. Each can land independently.

🦊

@mgreter

mgreter commented May 12, 2026

Copy link
Copy Markdown
Contributor

Thanks for contributing back, will take a look at it soonish. I may see a way to do this a bit more "backwards" compatible, but having a working example certainly helps with improving this approach a bit more. One main thing though, I don't intend to put it under MIT license, as I have kept most my mods a bit more closed. So question is if that is an issue for your contribution or not? Thanks again and have a nice week!

@mgreter

mgreter commented May 12, 2026

Copy link
Copy Markdown
Contributor

So I took a quick look and wonder if it wouldn't be possible for your mod to patch everything you need in my mod in order to make it seamlessly compatible more easily? I'm willing to refactor some parts to make that easier for you, since I see one or two changes that might be a bit difficult to patch, but other than those it should be possible to patch my mod with Harmony from your other mod!?

@AdaInTheLab

Copy link
Copy Markdown
Author

This made my day, thank you ~ honestly I'd been hoping for a path forward that doesn't involve maintaining a fork forever. Yes, very much yes to this.
Here's the surface I'm currently changing in my fork (Kitsune-Den/OcbCustomTextures) so you can see exactly what I'd need patchable from PaintUnlocked's side:

1. BlockTextureData.list sizing
I pre-size the list to 1024 from PU's InitMod, but your InitOpaqueConfig grows/reshapes it during paint registration. I patch your method (via Harmony postfix) to extend it past the 256 cap and avoid IndexOutOfRange on insert. If the array sizing inside InitOpaqueConfig could either (a) read from a static int field I can override, or (b) call a virtual method I can patch, that'd let me drop the postfix.

2. Custom paint ID floor
I currently transpile GetFreePaintID to seed allocation at 512 instead of starting right after the vanilla count. This is to reserve a stable ID range for PU-managed paints so legacy worlds don't ID-collide when paints get added/removed across sessions. If GetFreePaintID could read a starting offset from a static field (default 0 = current behavior), I'd just set that field from PU's side at init.

3. Atlas dimensioning
A few constants in your texture-array resize path are inlined at 256 (or thereabouts). I transpile them to 1024. If those became named static readonly ints, the transpile would be unnecessary.

4. The bundle-collision case (separate from PU but related)
This isn't a PU concern but a paint-pack ecosystem one ~ when multiple packs ship bundles with overlapping internal asset names, only the first loads. I worked around it on the build side (paint.kitsuneden.net now namespaces asset paths by pack ID at build time). If your AssetBundleManager wrapper had an "evict colliding bundle before load" mode I'd use it, but it's nice-to-have not need-to-have.

Things I'd genuinely call "hard to patch":
Not sealed classes or static fields ~ Harmony handles those fine. The actually brittle stuff is inlined constants inside foreach/for loops or array initializers, since the IL pattern shifts subtly between game versions and my transpiler matchers break. If those few constants became named fields, the patches stop being version-fragile.

I'm happy to:

  • Send you the current Harmony patch source so you can see exactly what I'm matching
  • Change the license to whatever you recommend
  • Open PRs against your repo for the field/refactor changes if that's useful, with PU side adapted in parallel
  • Test against your work-in-progress changes before you tag a release

End state I'd love: users install your official OCB from Nexus + my PaintUnlocked, and they just work together. Fork retired. One less pitfall in the install guide.

Let me know how you want to coordinate ~ GitHub issue, Discord, email, whatever works for you. Thanks again for reaching out.
🦊

@mgreter

mgreter commented May 21, 2026

Copy link
Copy Markdown
Contributor

Will try to take a look at the PR this weekend (no guarantee though). Can you maybe elaborate a little what it changes and if it is already what you would like to have for it to be compatible with your extension mod? Also, does it contain any change that is incompatible with how it was working before. Will gladly like to collaborate via GitHub here. E.g. just change it the way you want, while keeping the original functionality intact without any impact on existing users would be best.

@AdaInTheLab

Copy link
Copy Markdown
Author

sorry for the lag here ~ just pushed a cleanup commit and an updated PR description that should make the "is this safe to merge" question a lot easier to answer. The previous version of this PR had fork-branding (LICENSE, README rewrite, ModInfo flip) and committed working-notes mixed in with the technical changes, which made the diff harder to read than it needed to be. All that came out in the most recent push; what's left is just the code.

Walking through your "is anything incompatible with how it was working before" question per item:

Harmony/OpaqueTextures.cs

  1. EarlyResizeBlockTextureList pre-sizes the list to 1024 on config-load. The dynamic-resize patches downstream handle the actual sizing decisions. Existing OCB callers don't observe a difference; only the pre-size log line appears.
  2. The BlockTextureData.Init prefix is pure safety net. The vanilla code path never produces an __instance.ID beyond the list's length, so the resize branch is dormant in vanilla OCB use.
  3. GetFreePaintID grow-instead-of-throw is the only real behavior change in vanilla terms. Used to throw "No more free Paint IDs" when the array filled at 255; now resizes by 256 and returns the next free slot. Happy to revert to throwing if you'd prefer to keep that as an explicit failure mode rather than a transparent grow.
  4. InitOpaqueConfig dedicated-server clamp ~ pure fix. builtinOpaques == -1 after the texture lookup previously fell through to incorrect ID assignment on headless installs. Clamping to 0 makes the headless path run cleanly.
  5. ApplyTextures Max() sizing ~ for vanilla users where texture.depth >= builtinOpaques, this returns the same value the original code did. Only differs when the texture allocation was smaller than the current builtinOpaques count (which is when the previous code would have undersized the atlas).

The 512 floor that the previous PR description mentioned isn't actually in the code anymore. I'd disabled it before the 5/12 conversation because the dynamic-resize approach made it unnecessary, but the description was still claiming it. Fixed in the new description.

Utils/OcbTextureUtils.cs

This one is independent of paint capacity. PatchTexture loops 0..dst.mipmapCount and asks src for mips that don't exist when src is smaller than dst (which happens whenever a small paint texture from a third-party pack is patched into a bigger atlas slot through here). Unity logs ERR Graphics.CopyTexture called with invalid source mip level (got 8, have 8 mips) for every iteration past src's last. Cap the loop at Math.Min(dst.mipmapCount, src.mipmapCount - offset) and bail when src has nothing usable at the current quality offset.

This benefits any heavy-paint-pack OCB install regardless of whether PaintUnlocked is in play. Happy to split it into its own one-file PR if that makes it easier to land independent of the rest of the conversation.

Still the same end-state preference

Your extension-points proposal from 5/12 is still the win for both of us. The specific upstream changes I'd want most (so PaintUnlocked can drop the rest of these Harmony patches entirely):

  1. builtinOpaques paint-ID floor + start offset as a public static int field PaintUnlocked can set from its InitMod
  2. The 256-ish inlined constants in the array-sizing path becoming named static readonly ints
  3. Maybe a virtual method on the array sizing path so PaintUnlocked can override it without transpiling

Happy to PR each of those as small individual changes against your repo whenever you have bandwidth. Zero deadline.

Let me know how you'd like to proceed ~ a few options:

  • Close this PR and submit just the OcbTextureUtils.cs mip-cap fix as a new clean one
  • Leave this open and iterate on the extension-points work in parallel
  • Whatever shape works for your review flow

🦊

@AdaInTheLab AdaInTheLab changed the title ⚙️ feat: PaintUnlocked compatibility — 512 ID floor, dynamic resize, atlas fix feat: paint-list dynamic resize + mip-cap fix for heavy paint packs May 28, 2026
Dedicated server installs have no GPU atlas, so the diffuse-texture
check leaves builtinOpaques at -1. That falls through to incorrect
ID assignment in the paint registration loop below. Clamping to 0
makes the headless install run cleanly ~ tiling.index is a render
concern, so the value doesn't matter on server beyond not being -1.
Previously threw 'No more free Paint IDs' when BlockTextureData.list
filled up at 255 entries. Now resizes by 256 and returns the next
slot. Only fires when the array is genuinely full, so vanilla-sized
configs never hit it ~ this is a transparent fallback for paint packs
that exceed the default capacity, not a behavior change for default
installs.
The old sizing 'texture.depth + OpaquesAdded' assumes texture.depth
reflects the current builtinOpaques count, which fails when the
initial texture was allocated against an older atlas size and the
current builtinOpaques is larger. The Max() takes the same value as
the original in the common case where texture.depth >= builtinOpaques,
and only changes behavior when the texture would otherwise be
undersized.
Hooks into BlockTexturesFromXML.CreateBlockTextures Prefix to grow
BlockTextureData.list to a 1024-entry floor before any chunk
deserialization can access high paint IDs. Without this, reloading
a save built against a larger texture set than the current process
can fit throws IndexOutOfRange from the chunk-paint accessor.

The minSize floor of 1024 covers the existing vanilla range plus
a margin large enough that paint packs with several hundred entries
don't trigger an additional resize during config-load.
Right before the paint registration loop, grow the array to fit the
upcoming ID assignments in 256-slot blocks. A small config gets a
small bump and a large pack doesn't trigger an array resize per
registered paint.

Complements the config-load-time pre-size (which handles save reload
ordering): this one handles the case where the config is freshly
parsed but the array is still vanilla-sized.
Final safety net: if any code path (e.g. chunk deserialization running
before InitOpaqueConfig fires) tries to register a BlockTextureData
with ID >= list.Length, auto-grow the array in 256-slot blocks.

This is a no-op for the common case where the array is already sized
correctly by the two pre-size hooks above; it exists to catch
ordering bugs where a high-ID paint gets touched before either
pre-size pass has run.
PatchTexture walked dst.mipmapCount unconditionally, which is correct
when src is the same size or larger (the '2k into 1k' case the quality
offset is designed for) but overruns the source when src is SMALLER
than dst. The loop would ask src for mip levels it doesn't have, and
Unity logs

  ERR Graphics.CopyTexture called with invalid source mip level
  (got 8, have 8 mips)

for every iteration past src's last. That floods the console at chunk
paint time, especially with third-party paint mods that ship small
paint textures (128×128, 8 mips) being patched into bigger atlas
slots (1024+, 11+ mips).

Cap at Math.Min(dst.mipmapCount, src.mipmapCount - offset) and bail
when src has nothing usable at the current quality offset. Destination's
smallest mip levels are left unwritten; those only matter at extreme
view distance and existing destination contents are preserved.

Independent of paint capacity ~ benefits any OCB install with mixed-
size paint packs.
@AdaInTheLab AdaInTheLab force-pushed the feature/paint-limit-1023 branch from 9dc9cae to f102967 Compare May 28, 2026 21:51
@AdaInTheLab

Copy link
Copy Markdown
Author

small follow-up ~ just force-pushed the branch reshaped into 7 per-topic commits so each one is a single review decision. the net diff is unchanged from what i described above, just the history is now:

  1. fix(opaque): clamp builtinOpaques to 0 on dedicated server
  2. fix(opaque): GetFreePaintID grows array instead of throwing
  3. fix(opaque): ApplyTextures sizes atlas via Max(depth, builtinOpaques)
  4. feat(opaque): pre-size BlockTextureData.list at config-load
  5. feat(opaque): pre-size BlockTextureData.list in InitOpaqueConfig
  6. feat(opaque): BlockTextureData.Init prefix safety net for on-demand grow
  7. fix(textureutils): cap PatchTexture mip loop at source's mipmapCount

if any of them don't sit right you can cherry-pick the ones you like and skip the rest, or i'm happy to drop individual commits on request. #7 is the one that's fully independent of paint capacity so that one stands on its own if you'd rather not deal with the paint-list stuff right now.

🦊

@mgreter

mgreter commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Thanks a lot for the work, really need to get some time to include this, much appreciated

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.

2 participants