diff --git a/adapters/bukkit/plugin/src/test/gameplay/editor-ownership.mjs b/adapters/bukkit/plugin/src/test/gameplay/editor-ownership.mjs new file mode 100644 index 000000000..bda2609d5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/editor-ownership.mjs @@ -0,0 +1,102 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { jsonFiles } from './studio-tools.mjs' +import { packKey, seed, setup, openStudio, closeStudio, exists, writeJson } from './studio-support.mjs' + +const structure = 'acceptance/bot-graph' + +async function playerWorld(context, username) { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === username) + return observation.worlds.find(entry => entry.id === player?.world) +} + +export default { + name: 'iris-editor-ownership', + description: 'Verify concurrent ordinary visitors, competing administrator rejection, and owner reconnect across two explicit phases.', + async run(context) { + const phase = context.options.command ?? 'exercise' + context.expect(['exercise', 'reconnect'].includes(phase), 'Choose exercise or reconnect') + const fixture = await setup(context) + context.expect(fixture.state.studioCompleted, 'Complete Studio acceptance first') + context.expect(await exists(path.join(fixture.pack, 'structures', `${structure}.json`)), 'Complete the Jigsaw authoring phase first') + let active + let jigsaw = false + let editor + let promoted = false + let retainForReconnect = false + try { + if (phase === 'reconnect') { + const owned = fixture.state.ownership + context.expect(owned && owned.username === context.bot.username, 'Reconnect with the same primary owner after exercise') + context.expect(owned.processId === (await context.observe()).processId, 'Reconnect requires the same running server') + await fixture.wait(async () => (await fixture.currentWorld()).id === owned.id, 'Owner did not reconnect to the retained workspace') + active = owned + jigsaw = true + await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000) + await context.command('/iris jigsaw goto workcell/spatial', /Selected Jigsaw Studio workcell/, 10000) + await closeStudio(context, fixture, active, true) + active = undefined + delete fixture.state.ownership + await writeJson(fixture.marker, fixture.state) + context.report.iris.ownership = { reconnected: true, retainedWorld: owned.id, closed: true } + return + } + context.expect(!fixture.state.ownership, 'Finish the pending reconnect phase before another exercise') + const visitor = await context.connectActor(`IrisView${context.server.port}`) + editor = await context.connectActor(`IrisEdit${context.server.port}`) + await context.step('Walk two ordinary visitors concurrently in the same Studio world', async () => { + active = await openStudio(context, fixture) + for (const [actor, centerX] of [[visitor, -8.5], [editor, 8.5]]) { + await context.command(`/minecraft:tp ${actor.bot.username} ${context.bot.username}`) + await context.command(`/minecraft:gamemode survival ${actor.bot.username}`) + await context.command(`/minecraft:tp ${actor.bot.username} ${centerX + 4} 97 0.5`) + } + await fixture.wait(async () => (await playerWorld(context, visitor.bot.username))?.id === active.id + && (await playerWorld(context, editor.bot.username))?.id === active.id, 'Both visitors did not enter the same Studio world') + const circuits = await Promise.all([ + visitor.actions.walkCircle({ center: { x: -8.5, y: 97, z: 0.5 }, radius: 4, laps: 1 }), + editor.actions.walkCircle({ center: { x: 8.5, y: 97, z: 0.5 }, radius: 4, laps: 1 }) + ]) + context.report.iris.visitors = { usernames: [visitor.bot.username, editor.bot.username], world: active.id, circuits } + await closeStudio(context, fixture, active) + const closedId = active.id + active = undefined + await fixture.wait(async () => (await playerWorld(context, visitor.bot.username))?.id !== closedId + && (await playerWorld(context, editor.bot.username))?.id !== closedId, 'Studio close did not evacuate every visitor') + }) + await context.step('Reject a competing administrator while retaining the owner workspace', async () => { + await context.command(`/minecraft:op ${editor.bot.username}`, /operator/i, 10000) + promoted = true + context.report.iris.editorRole = 'Temporary administrator, explicitly promoted after ordinary-visitor coverage and deopped in cleanup' + active = await openStudio(context, fixture, `/iris jigsaw open ${packKey} ${structure} seed=${seed}`) + jigsaw = true + const resourceFiles = await jsonFiles(path.join(fixture.pack, 'jigsaw-pieces')) + const variants = await Promise.all(resourceFiles.map(async file => [file, await readFile(file, 'utf8')])) + const structureFile = path.join(fixture.pack, 'structures', `${structure}.json`) + const original = await readFile(structureFile, 'utf8') + await editor.command(`/iris jigsaw open ${packKey} ${structure} seed=${seed}`, /Jigsaw Studio is already owned by another player session\./, 10000) + context.expect((await fixture.currentWorld()).id === active.id, 'Competing open displaced the owner') + await context.command(`/minecraft:tp ${editor.bot.username} ${context.bot.username}`) + await fixture.wait(async () => (await playerWorld(context, editor.bot.username))?.id === active.id, 'Competing editor did not arrive') + await editor.command('/iris jigsaw variant label UnauthorizedChange', /owned by another player session/i, 10000) + await editor.command('/iris jigsaw close', /owned by another player session/i, 10000) + context.expect(await readFile(structureFile, 'utf8') === original, 'Rejected editor changed the structure') + for (const [file, bytes] of variants) context.expect(await readFile(file, 'utf8') === bytes, 'Rejected editor changed a variant') + await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000) + context.expect((await fixture.currentWorld()).id === active.id, 'Rejected close displaced the owner') + await context.command(`/minecraft:deop ${editor.bot.username}`, /operator/i, 10000) + promoted = false + fixture.state.ownership = { id: active.id, key: active.key, name: active.name, username: context.bot.username, + processId: (await context.observe()).processId } + await writeJson(fixture.marker, fixture.state) + context.report.iris.ownership = { competingOpenRejected: true, mutationRejected: true, closeRejected: true, retainedWorld: active.id } + context.report.iris.next = 'Run this scenario with --command reconnect against the same running server and primary username.' + retainForReconnect = true + }) + } finally { + if (promoted) await context.command(`/minecraft:deop ${editor.bot.username}`, /operator/i, 10000) + if (active && !retainForReconnect) await closeStudio(context, fixture, active, jigsaw) + } + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/studio-acceptance.mjs b/adapters/bukkit/plugin/src/test/gameplay/studio-acceptance.mjs new file mode 100644 index 000000000..fd3d1fba8 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/studio-acceptance.mjs @@ -0,0 +1,188 @@ +import { readFile, writeFile, cp } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { packKey, worldKey, seed, parsePhase, setup, exists, writeJson, readJson, + openStudio, closeStudio, sampleTerrain, logPosition, logSince } from './studio-support.mjs' + +async function prepare(context, fixture) { + context.expect(!(await exists(fixture.marker)) && !(await exists(fixture.pack)), 'Prepare requires a new disposable instance and an unused fixture pack') + const settingsFile = path.join(fixture.irisRoot, 'iris.json') + const settings = await readJson(settingsFile) + settings.studio = { ...settings.studio, openVSCode: false, entitySpawning: false } + await writeJson(settingsFile, settings) + await context.command(`/iris studio create name=${packKey}`, new RegExp(`Created studio project '${packKey}'`), 180000) + for (const relative of [`dimensions/${packKey}.json`, 'regions/starter.json', 'biomes/starter.json', `${packKey}.code-workspace`]) { + context.expect(await exists(path.join(fixture.pack, relative)), `Studio create omitted ${relative}`) + } + const source = fileURLToPath(new URL('./native-terrain-pack/', import.meta.url)) + for (const directory of ['regions', 'biomes', 'generators']) { + await cp(path.join(source, directory), path.join(fixture.pack, directory), { recursive: true }) + } + const dimension = await readJson(path.join(source, 'dimensions', 'native-terrain.json')) + dimension.name = 'Bot Studio Acceptance' + await writeJson(path.join(fixture.pack, 'dimensions', `${packKey}.json`), dimension) + const state = { schemaVersion: 1, instance: fixture.root, pack: packKey, preparedProcess: (await context.observe()).processId, + material: 'lime_wool', productionCreated: false, studioCompleted: false } + await writeJson(fixture.marker, state) + context.report.iris.next = 'Restart the isolated server before the studio phase so its pack registry is loaded.' +} + +async function editBiome(context, fixture, world, material, title) { + const file = path.join(fixture.pack, 'biomes', 'flat.json') + const manifestFile = fixture.manifestPath(world.key) + const before = await readJson(manifestFile) + const biome = await readJson(file) + biome.name = title + biome.layers[0].palette[0].block = `minecraft:${material}` + await writeJson(file, biome) + const after = await fixture.wait(async () => { + const manifest = await readJson(manifestFile) + return manifest.activeActivationId !== before.activeActivationId ? manifest : false + }, 'The saved pack edit did not activate through the Studio watcher', 90000) + context.report.iris.activations.push({ world: world.id, before: before.activeActivationId, after: after.activeActivationId, material }) +} + +async function studio(context, fixture, cycles) { + context.expect((await context.observe()).processId !== fixture.state.preparedProcess, 'Restart the server after prepare before opening the new pack') + let active + const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json') + let retainedBytes + try { + for (let cycle = 0; cycle < cycles; cycle++) { + await context.step(`Open Studio and inspect the accepted pack, cycle ${cycle + 1}`, async () => { + active = await openStudio(context, fixture) + await fixture.wait(() => context.bot.game.gameMode === 'spectator', 'Ordinary Studio did not enter spectator mode') + await sampleTerrain(context, fixture, 0, fixture.state.material) + }) + await context.step(`Apply a real pack edit and inspect new terrain, cycle ${cycle + 1}`, async () => { + const material = fixture.state.material === 'lime_wool' ? 'magenta_wool' : 'lime_wool' + await editBiome(context, fixture, active, material, `Acceptance Cycle ${cycle + 1}`) + await sampleTerrain(context, fixture, 4096, material) + await sampleTerrain(context, fixture, 0, fixture.state.material) + fixture.state.material = material + await writeJson(fixture.marker, fixture.state) + }) + await context.step(`Reject a malformed edit while retaining live generation, cycle ${cycle + 1}`, async () => { + retainedBytes = await readFile(biomeFile, 'utf8') + const before = await readJson(fixture.manifestPath(active.key)) + const logOffset = await logPosition(fixture) + await writeFile(biomeFile, '{ "name": "Rejected fixture", "layers": [') + const rejection = await fixture.wait(async () => { + const log = await logSince(fixture, logOffset) + return /Studio generation update failed/.test(log) ? log : false + }, 'The malformed edit did not produce a Studio rejection', 90000) + context.report.iris.rejections ??= [] + context.report.iris.rejections.push({ world: active.id, evidence: rejection.split('\n').filter(line => /Studio generation update failed/.test(line)) }) + context.expect((await readJson(fixture.manifestPath(active.key))).activeActivationId === before.activeActivationId, + 'A rejected edit changed the active generation') + await sampleTerrain(context, fixture, 8192, fixture.state.material) + await writeFile(biomeFile, retainedBytes) + retainedBytes = undefined + await editBiome(context, fixture, active, fixture.state.material, `Recovered Cycle ${cycle + 1}`) + await sampleTerrain(context, fixture, 12288, fixture.state.material) + }) + await context.step(`Close and remove the transient world, cycle ${cycle + 1}`, async () => { + await closeStudio(context, fixture, active) + active = undefined + }) + } + await context.step('Reopen the last accepted pack and verify persistence', async () => { + active = await openStudio(context, fixture) + await sampleTerrain(context, fixture, 0, fixture.state.material) + await closeStudio(context, fixture, active) + active = undefined + }) + fixture.state.studioCompleted = true + await writeJson(fixture.marker, fixture.state) + } finally { + if (retainedBytes !== undefined) await writeFile(biomeFile, retainedBytes) + if (active) await closeStudio(context, fixture, active) + } +} + +async function production(context, fixture, create) { + context.expect(fixture.state.studioCompleted, 'Complete the Studio acceptance phase before creating a production world') + const key = `iris:${worldKey}` + if (create) { + context.expect(!(await exists(fixture.worldRoot(key))), 'The production fixture world already exists') + await context.command(`/iris create name=${worldKey} type=${packKey} seed=${seed}`, /Successfully created your world/i, 180000) + fixture.state.productionCreated = true + fixture.state.productionProcess = (await context.observe()).processId + fixture.state.productionMaterial = fixture.state.material + await writeJson(fixture.marker, fixture.state) + } + context.expect(fixture.state.productionCreated, 'Create the production fixture before world-check') + if (!create) context.expect((await context.observe()).processId !== fixture.state.productionProcess, 'Run world-check after a real server restart') + const previous = await fixture.currentWorld() + const world = await fixture.arrival(() => context.command(`/iris tp ${worldKey}`), + candidate => candidate.name === worldKey || candidate.name === `iris_${worldKey}` || candidate.name.endsWith(`_iris_${worldKey}`), 'The bot did not enter the created Iris world') + try { + await context.command('/minecraft:gamemode spectator @s') + await fixture.wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode did not reach the bot') + await context.command('/minecraft:seed', new RegExp(`Seed:.*${seed}`)) + await sampleTerrain(context, fixture, create ? 0 : 4096, fixture.state.productionMaterial) + { + const file = path.join(fixture.pack, 'biomes', 'flat.json') + const original = await readFile(file, 'utf8') + try { + const edited = JSON.parse(original) + edited.layers[0].palette[0].block = 'minecraft:gold_block' + await writeJson(file, edited) + await sampleTerrain(context, fixture, 8192, fixture.state.productionMaterial) + } finally { await writeFile(file, original) } + } + const manifest = await readJson(fixture.manifestPath(key)) + context.expect(manifest.epochs.length > 0 && manifest.pendingActivationId === null, 'Production world has no active immutable generation epoch') + context.report.iris.productionManifest = manifest + } finally { + await fixture.arrival(() => context.command(`/minecraft:execute in minecraft:overworld run tp ${context.bot.username} 0 110 0`), + candidate => candidate.id !== world.id, 'The bot did not leave the production world') + } + context.expect(previous.id !== world.id || !create, 'Creation did not establish a separate world') +} + +async function replacement(context, fixture, check) { + context.expect(fixture.state.productionCreated, 'Complete production-world creation before replacing the disposable vanilla world') + const key = 'minecraft:overworld' + if (!check) { + context.expect(!fixture.state.replacement && !(await exists(fixture.manifestPath(key))), 'Replacement requires the original vanilla fixture world') + context.expect(await exists(path.join(fixture.worldRoot(key), 'data', 'minecraft', 'world_gen_settings.dat')), 'The exact vanilla dimension has no saved generation settings') + const overworld = (await context.observe()).worlds.find(world => world.name === fixture.level) + context.expect(overworld, 'Observer did not identify the vanilla Overworld') + fixture.state.replacement = { processId: (await context.observe()).processId, previousId: overworld.id, staged: false } + await writeJson(fixture.marker, fixture.state) + await context.command(`/iris replace ${key} type=${packKey} seed=${seed}`, /Staged Iris replacement for minecraft:overworld.*Restart once to publish/i, 180000) + fixture.state.replacement.staged = true + fixture.state.replacement.material = fixture.state.material + await writeJson(fixture.marker, fixture.state) + context.report.iris.next = 'Restart the isolated server, then run replace-check.' + return + } + context.expect(fixture.state.replacement?.staged, 'No successful replacement stage was recorded') + context.expect((await context.observe()).processId !== fixture.state.replacement.processId, 'Replacement must be checked after a real server restart') + await fixture.arrival(() => context.command(`/minecraft:execute in ${key} run tp ${context.bot.username} 0 110 0`), + candidate => candidate.name === fixture.level, 'The bot did not enter the replaced canonical Overworld') + await context.command('/minecraft:gamemode spectator @s') + await fixture.wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode did not reach the bot') + await context.command('/minecraft:seed', new RegExp(`Seed:.*${seed}`)) + await sampleTerrain(context, fixture, 0, fixture.state.replacement.material) + await sampleTerrain(context, fixture, 4096, fixture.state.replacement.material) + const manifest = await readJson(fixture.manifestPath(key)) + context.expect(manifest.epochs.length > 0 && manifest.pendingActivationId === null, 'Replacement did not publish an immutable Iris generation') + context.report.iris.replacementManifest = manifest +} + +export default { + name: 'iris-studio-acceptance', + description: 'Exercise public Studio creation, repeated pack edits, rejection retention, lifecycle, production creation, and cold vanilla replacement.', + async run(context) { + const { phase, cycles } = parsePhase(context.options.command) + const fixture = await setup(context, { prepare: phase === 'prepare' }) + if (phase === 'prepare') return context.step('Create and prepare a disposable Studio pack through its public command', () => prepare(context, fixture)) + if (phase === 'studio') return studio(context, fixture, cycles) + if (phase === 'world' || phase === 'world-check') return context.step('Verify a production world through public commands and terrain', () => production(context, fixture, phase === 'world')) + if (phase === 'replace-stage' || phase === 'replace-check') return context.step(phase, () => replacement(context, fixture, phase === 'replace-check')) + const tools = await import('./studio-tools.mjs') + return tools.runTools(context, fixture, phase) + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/studio-support.mjs b/adapters/bukkit/plugin/src/test/gameplay/studio-support.mjs new file mode 100644 index 000000000..47fa3577d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/studio-support.mjs @@ -0,0 +1,143 @@ +import { readFile, writeFile, realpath, stat, open } from 'node:fs/promises' +import path from 'node:path' + +export const packKey = 'qa-bot-studio' +export const worldKey = 'qa-bot-world' +export const seed = 78264193 + +export function parsePhase(value = 'studio:3') { + const match = /^(prepare|studio|object|jigsaw|world|world-check|replace-stage|replace-check)(?::([1-9]\d?))?$/.exec(value) + if (!match || (match[2] && match[1] !== 'studio')) throw new Error(`Invalid Iris acceptance phase: ${value}`) + const cycles = Number(match[2] ?? 3) + if (cycles > 20) throw new Error('Studio cycles must be between 1 and 20') + return { phase: match[1], cycles } +} + +export async function exists(file) { + try { await stat(file); return true } catch (error) { + if (error.code === 'ENOENT') return false + throw error + } +} + +export async function writeJson(file, value) { + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`) +} + +export async function readJson(file) { + return JSON.parse(await readFile(file, 'utf8')) +} + +export function ownedPath(root, ...segments) { + const target = path.resolve(root, ...segments) + if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw new Error('Fixture path escapes the isolated instance') + return target +} + +export async function setup(context, { prepare = false } = {}) { + const directory = context.server.directory + context.expect(typeof directory === 'string' && path.isAbsolute(directory), 'The harness must supply an absolute instance directory') + const root = await realpath(directory) + const source = await readFile(path.join(root, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Iris acceptance requires an isolated instance') + const properties = await readFile(path.join(root, 'server.properties'), 'utf8') + context.expect(/^server-ip=127\.0\.0\.1\s*$/m.test(properties), 'Iris acceptance requires a loopback instance') + const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world' + const levelRoot = ownedPath(root, level) + const irisRoot = await realpath(path.join(root, 'plugins', 'Iris')) + context.expect(irisRoot.startsWith(`${root}${path.sep}`), 'Iris data must belong to this instance') + const packs = await realpath(path.join(irisRoot, 'packs')) + context.expect(packs.startsWith(`${root}${path.sep}`), 'Shared Iris packs are not a test target') + const marker = path.join(root, '.multiplexor-iris-acceptance.json') + const state = prepare ? null : await readJson(marker) + if (state) { + context.expect(state.schemaVersion === 1 && state.instance === root && state.pack === packKey, + 'The prepared fixture does not belong to this instance') + } + const pack = path.join(packs, packKey) + if (state) context.expect((await realpath(pack)).startsWith(`${root}${path.sep}`), 'The owned fixture pack was redirected outside the instance') + const worldRoot = key => ownedPath(levelRoot, 'dimensions', ...key.split(':')) + const manifestPath = key => path.join(worldRoot(key), 'iris', 'generation', 'manifest.json') + await context.waitUntil(async () => (await context.observe()).players.some(entry => entry.username === context.bot.username), + { timeoutMs: 15000, label: 'observer registration for the scenario player' }) + const currentWorld = async () => { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === context.bot.username) + context.expect(player, 'Observer does not contain the scenario player') + const world = observation.worlds.find(entry => entry.id === player.world) + context.expect(world, 'Observer does not contain the player world') + return world + } + const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 }) + const arrival = async (trigger, predicate, label) => { + const previous = await context.observe() + const startedAt = Date.now() + let refusal + const messages = message => { + if (/Cannot convert |Unknown parameter|Missing required|Jigsaw project was not created|Studio open failed|restart before opening|Could not resolve the requested Iris pack/.test(message)) refusal = message + } + context.bot.on('messagestr', messages) + try { + await trigger() + const world = await wait(async () => { + context.expect(!refusal, `Public Iris command refused: ${refusal}`) + const observation = await context.observe() + context.expect(observation.processId === previous.processId, 'Server restarted during Studio arrival') + if (Date.parse(observation.observedAt) <= startedAt) return false + const current = await currentWorld() + return predicate(current) && current.loadedChunks > 0 ? current : false + }, label, 180000) + await wait(() => context.bot.blockAt(context.bot.entity.position) !== null, 'Destination chunk did not reach the bot') + context.report.iris.worldVisits.push({ id: world.id, name: world.name, observedAt: (await context.observe()).observedAt }) + return world + } finally { context.bot.removeListener('messagestr', messages) } + } + context.report.iris = { phase: context.options.command ?? 'studio:3', pack: packKey, worldVisits: [], columns: [], activations: [] } + return { root, level, levelRoot, irisRoot, pack, marker, state, worldRoot, manifestPath, currentWorld, wait, arrival } +} + +export async function openStudio(context, fixture, command = `/iris studio open ${packKey} seed=${seed}`) { + const before = new Set((await context.observe()).worlds.map(world => world.id)) + const world = await fixture.arrival(() => context.command(command), + candidate => !before.has(candidate.id) && /iris-[0-9a-f-]{36}$/.test(candidate.name), 'The public Studio command did not create and enter a new transient world') + const name = /iris-[0-9a-f-]{36}$/.exec(world.name)[0] + return { ...world, key: `iris:${name}` } +} + +export async function closeStudio(context, fixture, world, jigsaw = false) { + await context.command(jigsaw ? '/iris jigsaw close' : '/iris studio close', + jigsaw ? /^Jigsaw Studio closed\.$/ : /^Studio closed\.$/, 180000) + await fixture.wait(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id), 'Studio world remained loaded after close', 180000) + await fixture.wait(async () => !(await exists(fixture.worldRoot(world.key))), 'Studio world directory survived cleanup', 180000) + context.expect((await fixture.currentWorld()).id !== world.id, 'Studio close did not evacuate the bot') +} + +export async function sampleTerrain(context, fixture, x, material, expectedHeight = 96) { + await context.command(`/minecraft:tp @s ${x + 0.5} ${expectedHeight + 5} 0.5`, /Teleported/i, 30000) + await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1, 'Terrain sample teleport did not arrive') + const samples = [] + for (const offset of [0, 1, 2, 3]) { + const target = context.bot.entity.position.clone().set(x + offset, expectedHeight, 0) + const block = await fixture.wait(() => context.bot.blockAt(target), 'Terrain column did not reach the bot', 60000) + const above = await fixture.wait(() => context.bot.blockAt(target.offset(0, 1, 0)), 'Terrain headroom did not reach the bot') + context.expect(block.name === material && above.name === 'air', 'Generated column differs from the fixture', + { x: x + offset, y: expectedHeight, expected: material, actual: block.name, above: above.name }) + samples.push({ x: x + offset, y: expectedHeight, block: block.name, above: above.name }) + } + context.report.iris.columns.push({ world: (await fixture.currentWorld()).id, samples }) +} + +export async function logPosition(fixture) { + return (await stat(path.join(fixture.root, 'logs', 'latest.log'))).size +} + +export async function logSince(fixture, offset) { + const handle = await open(path.join(fixture.root, 'logs', 'latest.log'), 'r') + try { + const size = (await handle.stat()).size + const start = Math.max(offset, size - 1024 * 1024) + const buffer = Buffer.alloc(Math.max(0, size - start)) + await handle.read(buffer, 0, buffer.length, start) + return buffer.toString('utf8') + } finally { await handle.close() } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/studio-support.test.mjs b/adapters/bukkit/plugin/src/test/gameplay/studio-support.test.mjs new file mode 100644 index 000000000..8835fe60b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/studio-support.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { parsePhase, ownedPath, setup, packKey } from './studio-support.mjs' + +test('bulk bounds and destructive phases require explicit valid selection', () => { + assert.deepEqual(parsePhase(), { phase: 'studio', cycles: 3 }) + assert.deepEqual(parsePhase('studio:20'), { phase: 'studio', cycles: 20 }) + for (const value of ['studio:0', 'studio:21', 'studio:999999', 'replace-stage:2', 'replace', 'studio;stop', '']) { + assert.throws(() => parsePhase(value)) + } +}) + +test('fixture paths reject absolute and parent escapes', () => { + assert.equal(ownedPath('/test/instance', 'world', 'dimensions'), '/test/instance/world/dimensions') + for (const value of ['..', '../instance-other', '/test/another', '.']) { + assert.throws(() => ownedPath('/test/instance', value)) + } +}) + +async function fixture(run) { + const base = await mkdtemp(path.join(os.tmpdir(), 'iris-gameplay-')) + const root = path.join(base, 'instance') + const packs = path.join(root, 'plugins', 'Iris', 'packs') + await mkdir(packs, { recursive: true }) + await writeFile(path.join(root, '.server-source'), 'type=paper\nisolated=true\n') + await writeFile(path.join(root, 'server.properties'), 'server-ip=127.0.0.1\nlevel-name=world\n') + const context = { + server: { directory: root }, options: {}, report: {}, bot: { username: 'FixtureBot' }, + expect: (condition, message) => assert.ok(condition, message) + } + try { await run({ base, root, packs, context }) } finally { await rm(base, { recursive: true, force: true }) } +} + +test('shared packs are refused even if the instance claims isolation', async () => fixture(async ({ base, packs, context }) => { + const shared = path.join(base, 'shared-packs') + await mkdir(shared) + await rm(packs, { recursive: true }) + await symlink(shared, packs) + await assert.rejects(setup(context, { prepare: true }), /Shared Iris packs/) +})) + +test('normal and non-loopback instances cannot enter acceptance', async () => fixture(async ({ root, context }) => { + await writeFile(path.join(root, '.server-source'), 'type=paper\n') + await assert.rejects(setup(context, { prepare: true }), /isolated instance/) + await writeFile(path.join(root, '.server-source'), 'isolated=true\n') + await writeFile(path.join(root, 'server.properties'), 'server-ip=0.0.0.0\n') + await assert.rejects(setup(context, { prepare: true }), /loopback instance/) +})) + +test('a copied ownership marker cannot authorize another instance', async () => fixture(async ({ root, context }) => { + await writeFile(path.join(root, '.multiplexor-iris-acceptance.json'), JSON.stringify({ + schemaVersion: 1, pack: packKey, instance: '/another/server' + })) + await assert.rejects(setup(context), /does not belong/) +})) diff --git a/adapters/bukkit/plugin/src/test/gameplay/studio-tools.mjs b/adapters/bukkit/plugin/src/test/gameplay/studio-tools.mjs new file mode 100644 index 000000000..b32a7c304 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/studio-tools.mjs @@ -0,0 +1,119 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { packKey, seed, exists, openStudio, closeStudio } from './studio-support.mjs' + +async function objectTools(context, fixture) { + const objectKey = 'acceptance-marker' + const objectFile = path.join(fixture.pack, 'objects', `${objectKey}.iob`) + context.expect(!(await exists(objectFile)), 'Object acceptance requires an unused marker object') + let active + try { + active = await openStudio(context, fixture) + await context.step('Select and save one real block through the Iris wand commands', async () => { + await context.command('/minecraft:gamemode creative @s', /creative/i) + await fixture.wait(() => context.bot.game.gameMode === 'creative', 'Creative mode did not reach the bot') + context.bot.creative.startFlying() + await context.command('/minecraft:tp @s 0.5 111 0.5', /Teleported/i) + await fixture.wait(() => Math.abs(context.bot.entity.position.y - 111) < 0.1, 'Object selection position did not arrive') + context.bot.creative.startFlying() + await context.command('/minecraft:setblock 0 110 0 minecraft:diamond_block', /changed/i) + await fixture.wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(0, 110, 0))?.name === 'diamond_block', 'Selection block did not reach the bot') + const existing = new Set(context.bot.inventory.items().map(item => item.slot)) + await context.command('/iris object wand') + const wand = await fixture.wait(() => context.bot.inventory.items().find(item => !existing.has(item.slot)), 'The Iris wand was not received') + await context.bot.equip(wand, 'hand') + for (const corner of ['position1', 'position2']) { + const before = JSON.stringify(context.bot.heldItem) + await context.command(`/iris object ${corner}`) + await fixture.wait(() => JSON.stringify(context.bot.heldItem) !== before, `${corner} did not update the wand selection`) + } + await context.command(`/iris object save ${objectKey} dimension=${packKey}`, /Successfully object to saved:/, 60000) + context.expect((await readFile(objectFile)).length > 0, 'Object save produced an empty IOB') + await context.command(`/iris object analyze ${objectKey}`, /Object Size: 1 \* 1 \* 1/, 10000) + }) + await context.step('Paste the saved object and undo the actual block change', async () => { + await context.command('/minecraft:tp @s 16.5 100 0.5', /Teleported/i) + await fixture.wait(() => Math.abs(context.bot.entity.position.x - 16.5) < 0.1, 'Paste position did not arrive') + context.bot.creative.startFlying() + const target = context.bot.entity.position.clone().set(16.5, 96.5, 0.5) + await fixture.wait(() => context.bot.blockAt(target), 'Paste target chunk did not arrive') + await context.bot.lookAt(target, true) + const placed = target.clone().set(16, 97, 0) + context.expect(context.bot.blockAt(placed)?.name === 'air', 'Paste target was not empty') + await context.command(`/iris object paste ${objectKey} scale=1`, /Placed/i, 30000) + await fixture.wait(() => context.bot.blockAt(placed)?.name === 'diamond_block', 'Saved object did not become a real block') + await context.command('/iris object undo', /Reverted 1.*pastes/i, 30000) + await fixture.wait(() => context.bot.blockAt(placed)?.name === 'air', 'Undo did not restore the original air block') + context.report.iris.object = { key: objectKey, saved: true, pasted: true, undone: true } + }) + } finally { + if (active) await closeStudio(context, fixture, active) + } +} + +export async function jsonFiles(directory) { + if (!(await exists(directory))) return [] + const files = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name) + if (entry.isDirectory()) files.push(...await jsonFiles(file)) + else if (entry.isFile() && entry.name.endsWith('.json')) files.push(file) + } + return files +} + +async function jigsawTools(context, fixture) { + const structure = 'acceptance/bot-graph' + context.expect(!(await exists(path.join(fixture.pack, 'structures', `${structure}.json`))), 'Jigsaw acceptance requires an unused structure key') + let active + try { + await context.step('Create and enter a Jigsaw project through the public command', async () => { + active = await openStudio(context, fixture, + `/iris jigsaw create ${packKey} ${structure} mode=spatial width=3 height=3 depth=3 seed=${seed}`) + await fixture.wait(() => context.bot.game.gameMode === 'creative', 'Jigsaw Studio did not enter creative mode') + context.expect(await exists(path.join(fixture.pack, 'structures', `${structure}.json`)), 'Jigsaw creation omitted the structure resource') + const status = await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000) + context.report.iris.jigsaw = { structure, createdStatus: status } + const opened = context.waitForEvent('windowOpen', window => window.inventoryStart === 54, 10000) + await context.command('/iris jigsaw menu') + const [window] = await opened + context.expect(window.slots.slice(0, 54).some(Boolean), 'Jigsaw controls inventory was empty') + await context.bot.closeWindow(window) + }) + await context.step('Rename the selected variant and persist its authored metadata', async () => { + await context.command('/iris jigsaw goto workcell/spatial', /Selected Jigsaw Studio workcell 'workcell\/spatial'\./, 10000) + const selection = await context.command('/iris jigsaw status', /selected=.*activeVariant=/, 10000) + const selected = /selected=([^,]+), activeVariant=([^,]+)/.exec(selection) + context.expect(selected && selected[1] !== 'none' && selected[2] !== 'none', 'Jigsaw did not select an owned variant', { selection }) + const label = 'BotAcceptanceVariant' + await context.command(`/iris jigsaw variant label ${label}`, /Renamed the variant to 'BotAcceptanceVariant'\./, 60000) + const files = await jsonFiles(path.join(fixture.pack, 'jigsaw-pieces')) + const saved = await fixture.wait(async () => { + for (const file of files) { + const content = await readFile(file, 'utf8') + if (content.includes(label)) return { file, content } + } + return false + }, 'Variant rename did not persist through the public authoring command', 60000) + context.report.iris.jigsaw.variantFile = path.relative(fixture.pack, saved.file) + context.report.iris.jigsaw.label = label + await closeStudio(context, fixture, active, true) + active = undefined + const bytes = await readFile(saved.file, 'utf8') + active = await openStudio(context, fixture, `/iris jigsaw open ${packKey} ${structure} seed=${seed}`) + context.expect(await readFile(saved.file, 'utf8') === bytes, 'Jigsaw reopen rewrote authored variant metadata') + await context.command('/iris jigsaw status', /mode=SPATIAL.*variants=7/, 10000) + const ready = await context.command('/iris jigsaw preview assemble seed=1337', /Showing a [1-9]\d*-piece particle preview for 10 seconds\./, 60000) + context.report.iris.jigsaw.previewResponse = ready + }) + } finally { + if (active) await closeStudio(context, fixture, active, true) + } +} + +export async function runTools(context, fixture, phase) { + context.expect(fixture.state.studioCompleted, 'Complete the Studio acceptance phase before object or Jigsaw authoring') + if (phase === 'object') return objectTools(context, fixture) + if (phase === 'jigsaw') return jigsawTools(context, fixture) + throw new Error(`Unknown Studio tools phase: ${phase}`) +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java index d92927c16..afab7678a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java @@ -52,6 +52,11 @@ public final class ModdedRegistries implements PlatformRegistries { return generationRegistry.get(); } + @Override + public NativeBlockState decodeBlockState(String key) { + return ModdedBlockResolution.BLOCKS.decode(key); + } + @Override public NativeBlockState block(String key) { return ModdedBlockResolution.BLOCKS.get(key); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStateOperationsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStateOperationsTest.java index 0f6ee757a..9b0f3921e 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStateOperationsTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStateOperationsTest.java @@ -2,6 +2,8 @@ package art.arcane.iris.modded; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStateMerger; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStateRotator; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -14,6 +16,10 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; public class NativeStateOperationsTest { @BeforeClass @@ -22,6 +28,34 @@ public class NativeStateOperationsTest { Bootstrap.bootStrap(); } + @Test + public void persistedLeafPropertiesBypassGenerationDefaults() { + NativeBlockResolver.Policy policy = mock(NativeBlockResolver.Policy.class); + NativeBlockResolver resolver = new NativeBlockResolver(policy); + ModdedRegistries registries = new ModdedRegistries(mock(NativeRegistryAccess.class), + () -> mock(PlatformGenerationRegistry.class)); + for (String key : new String[]{ + "minecraft:oak_leaves[distance=2,persistent=false,waterlogged=true]", + "minecraft:oak_leaves[distance=6,persistent=true,waterlogged=false]", + "minecraft:oak_stairs[facing=west,half=top,shape=inner_left,waterlogged=true]"}) { + assertEquals(NativeBlockResolver.strictParse(key).key(), resolver.decode(key).key()); + assertEquals(NativeBlockResolver.strictParse(key).key(), registries.decodeBlockState(key).key()); + } + verifyNoInteractions(policy); + } + + @Test + public void persistedCustomStateRetainsDeferredPlacementIdentity() { + String key = "test:persisted_stairs[facing=west]"; + ModdedBlockState nativeState = NativeBlockResolver.strictParse("minecraft:oak_stairs[facing=west]"); + ModdedBlockState custom = nativeState.withDeferredPlacement(key); + NativeBlockResolver.Policy policy = mock(NativeBlockResolver.Policy.class); + when(policy.resolveCustomBlock(key)).thenReturn(custom); + + assertSame(custom, new NativeBlockResolver(policy).decode(key)); + assertEquals(key, custom.deferredPlacementKey()); + } + @Test public void rotationPreservesDeferredPlacementMetadata() { ModdedBlockState parsed = NativeBlockResolver.strictParse("minecraft:oak_stairs[facing=north]"); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java new file mode 100644 index 000000000..bcc4d1abc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java @@ -0,0 +1,75 @@ +package art.arcane.iris.generation.hydrology; + +final class HydrologyCacheBudget { + private static final long MEBIBYTE = 1024L * 1024L; + private static final HydrologyCacheBudget REGIONAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), true); + private static final HydrologyCacheBudget LOCAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), false); + private final long plannerBytes; + private final long publicationBytes; + private final boolean regionalEnabled; + + private HydrologyCacheBudget(long retainedBytes, boolean regionalEnabled) { + plannerBytes = retainedBytes / 2L; + publicationBytes = retainedBytes - plannerBytes; + this.regionalEnabled = regionalEnabled; + } + + static HydrologyCacheBudget runtime() { + return REGIONAL_RUNTIME; + } + + static HydrologyCacheBudget runtime(boolean regionalEnabled) { + return regionalEnabled ? REGIONAL_RUNTIME : LOCAL_RUNTIME; + } + + static HydrologyCacheBudget forHeap(long maximumHeapBytes, boolean regionalEnabled) { + if (maximumHeapBytes <= 0L) { + throw new IllegalArgumentException("Maximum heap size must be positive."); + } + return new HydrologyCacheBudget(Math.clamp(maximumHeapBytes / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE), regionalEnabled); + } + + static long sharedStudioBytes() { + return Math.clamp(Runtime.getRuntime().maxMemory() / 128L, MEBIBYTE, 16L * MEBIBYTE); + } + + long tileBytes() { + return publicationBytes * 3L / 4L; + } + + long columnBytes() { + return publicationBytes / 8L; + } + + long diagnosticBytes() { + return publicationBytes / 8L; + } + + long routingBytes() { + return regionalEnabled ? plannerBytes / 8L : plannerBytes * 3L / 16L; + } + + long ownerBytes() { + return regionalEnabled ? plannerBytes / 4L : plannerBytes * 3L / 4L; + } + + long edgeBytes() { + return plannerBytes / 16L; + } + + long regionalDraftBytes() { + return regionalEnabled ? plannerBytes / 8L : 0L; + } + + long regionalCoarseBytes() { + return regionalEnabled ? plannerBytes / 8L : 0L; + } + + long regionalTerrainBytes() { + return regionalEnabled ? plannerBytes / 4L : 0L; + } + + long regionalReachBytes() { + return regionalEnabled ? plannerBytes / 16L : 0L; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java new file mode 100644 index 000000000..69674efdb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java @@ -0,0 +1,123 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; + +import java.util.List; + +final class HydrologyCacheWeights { + private static final long ENTRY_BYTES = 192L; + private static final long TERRAIN_BYTES = 256L; + private static final long POINT_BYTES = 40L; + + private HydrologyCacheWeights() { + } + + static int bounded(long estimatedBytes, long budget, int maximumEntries) { + return saturated(Math.max(estimatedBytes, Math.ceilDiv(budget, maximumEntries))); + } + + static int bounded(long estimatedBytes, long budget, int maximumEntries, int units, int maximumUnits) { + return bounded(Math.max(estimatedBytes, Math.ceilDiv((long) units * budget, maximumUnits)), budget, maximumEntries); + } + + static int tile(HydrologyTile tile) { + long bytes = network(tile.nodes(), tile.edges(), tile.outlets(), tile.courses(), tile.cavePlans()); + bytes += diagnostics(tile.localDiagnosticCandidates()); + bytes += (long) tile.regionalCourseIds().size() * 64L; + RiverFootprint footprint = tile.footprint(); + if (footprint != null) { + bytes += (long) footprint.size() * 64L; + for (HydrologyColumnSample sample : footprint.columns().values()) { + bytes += column(sample); + } + } + return saturated(bytes); + } + + static int column(HydrologyColumnSample sample) { + return saturated(96L + (long) sample.layers().size() * 192L); + } + + static int diagnostics(List diagnostics) { + return saturated(ENTRY_BYTES + (long) diagnostics.size() * 112L); + } + + static int routing(SourceRoutingContext context) { + return saturated(ENTRY_BYTES + grid(context.grid()) + routingPlan(context.surfaceRouting()) + + routingPlan(context.undergroundRouting()) + diagnostics(context.diagnostics())); + } + + static int owner(CrossTileResolvedOwner owner) { + HydrologyCaveCourseFilter.Result result = owner.draft().result(); + long bytes = network(result.nodes(), result.edges(), result.outlets(), result.courses(), result.cavePlans()) + + diagnostics(owner.draft().diagnostics()); + for (CrossTileRejectedCourse rejection : owner.observedRejections()) { + bytes += 48L + course(rejection.course()); + } + return saturated(bytes); + } + + static int points(List points) { + return saturated(ENTRY_BYTES + (long) points.size() * POINT_BYTES); + } + + static int regionalNetwork(HydrologyRegionalNetwork network) { + return saturated(network(network.nodes(), network.edges(), network.outlets(), network.courses(), network.cavePlans()) + + diagnostics(network.diagnostics())); + } + + static int regionalCoarse(HydrologyRegionalCoarsePlan plan) { + HydrologyRegionalGraph.Tree tree = plan.tree(); + long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L + + (long) tree.accumulation().length * 4L + tree.retainedLabelCount() * 96L; + return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L + + (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L); + } + + static int terrain(HydrologyTerrainSample terrain) { + return saturated(128L + (terrain == null ? 0L : TERRAIN_BYTES)); + } + + private static long grid(HydrologySampledGrid grid) { + return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES); + } + + private static long routingPlan(HydrologyRoutingPlan plan) { + if (plan == null) { + return 0L; + } + return 128L + (long) plan.potential().length * 8L + + ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L + + (long) plan.outlets().size() * 224L; + } + + private static long network(List nodes, List edges, List outlets, + List courses, List cavePlans) { + long bytes = ENTRY_BYTES + (long) nodes.size() * (96L + TERRAIN_BYTES) + (long) outlets.size() * 224L; + for (DrainageEdge edge : edges) { + bytes += 96L + points(edge.centerline()); + } + for (RiverCourse course : courses) { + bytes += course(course); + } + for (HydrologyCavePlan plan : cavePlans) { + bytes += plan.estimatedRetainedBytes(); + } + return bytes; + } + + private static long course(RiverCourse course) { + long bytes = 128L; + for (DrainageEdge edge : course.drainageEdges()) { + bytes += 96L + points(edge.centerline()); + } + for (HydraulicSegment segment : course.segments()) { + bytes += 128L + points(segment.centerline()) + (long) segment.channelProfile().size() * 16L; + } + return bytes; + } + + private static int saturated(long bytes) { + return (int) Math.clamp(bytes, 1L, Integer.MAX_VALUE); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java index 858762125..7badfa9a5 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Callable; +import java.util.concurrent.ForkJoinWorkerThread; final class HydrologyCrossTileResolver { private static final int FALLBACK_TRIAL_PARALLELISM = 16; @@ -283,11 +284,20 @@ final class HydrologyCrossTileResolver { List outlets, int start ) { - int end = Math.min(outlets.size(), start + FALLBACK_TRIAL_PARALLELISM); + int parallelism = HydrologyPlanningAdmission.effectiveParallelism( + Thread.currentThread() instanceof ForkJoinWorkerThread worker + ? worker.getPool().getParallelism() + : IrisPlatforms.isBound() ? MultiBurst.hydrology.parallelism() : 1); + int batchSize = parallelism <= 2 ? 1 : Math.max(1, Math.min(HydrologyPlanningAdmission.maximumTrials(), + Math.min(FALLBACK_TRIAL_PARALLELISM, parallelism))); + int end = Math.min(outlets.size(), start + batchSize); ArrayList> tasks = new ArrayList<>(end - start); + HydrologyPlanner.PlanningSamples ownerSamples = planner.planningSamples.get(); + HydrologyLandHeightCache sharedLandHeights = planner.naturalSampler == null ? null + : ownerSamples == null ? new HydrologyLandHeightCache() : ownerSamples.fallbackLandHeights(); for (int index = start; index < end; index++) { OutletCandidate outlet = outlets.get(index); - tasks.add(() -> compileFallbackTrial(context, outlet)); + tasks.add(() -> compileFallbackTrial(context, outlet, sharedLandHeights)); } List trials = HydrologyForkJoin.invokeAll(tasks, IrisPlatforms.isBound() ? MultiBurst.hydrology : null); @@ -305,7 +315,11 @@ final class HydrologyCrossTileResolver { return trials; } - private FallbackTrial compileFallbackTrial(FallbackContext context, OutletCandidate outlet) { + private FallbackTrial compileFallbackTrial( + FallbackContext context, + OutletCandidate outlet, + HydrologyLandHeightCache sharedLandHeights + ) { HydrologyPlanner.PlanningSamples previousSamples = planner.planningSamples.get(); DraftProfile previousProfile = planner.draftProfiles.get(); DraftProfile profile = new DraftProfile(); @@ -314,7 +328,7 @@ final class HydrologyCrossTileResolver { HashMap sourceCompilations = new HashMap<>(context.sourceCompilations()); HydrologyFootprintCompiler footprints = null; try { - planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(sharedLandHeights)); planner.draftProfiles.set(profile); footprints = new HydrologyFootprintCompiler( planner.settings, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java index 19184463d..9b8752c0f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java @@ -391,7 +391,7 @@ final class HydrologyFootprintCompiler { } RiverFootprint build(Long2ObjectLinkedOpenHashMap columns) { - LinkedHashMap immutable = new LinkedHashMap<>(); + Long2ObjectOpenHashMap immutable = new Long2ObjectOpenHashMap<>(columns.size()); for (Long2ObjectMap.Entry entry : columns.long2ObjectEntrySet()) { immutable.put(entry.getLongKey(), entry.getValue().build()); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyLandHeightCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyLandHeightCache.java new file mode 100644 index 000000000..0dce954a4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyLandHeightCache.java @@ -0,0 +1,71 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.volmlib.util.cache.CacheKey; +import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2DoubleMap; + +final class HydrologyLandHeightCache { + private static final int MAXIMUM_ENTRIES = 131_072; + private static final int MAXIMUM_STRIPES = 64; + private final Stripe[] stripes; + + HydrologyLandHeightCache() { + this(MAXIMUM_ENTRIES); + } + + HydrologyLandHeightCache(int maximumEntries) { + if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) { + throw new IllegalArgumentException("Land height cache size must be a positive power of two."); + } + int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries); + stripes = new Stripe[stripeCount]; + for (int index = 0; index < stripeCount; index++) { + stripes[index] = new Stripe(maximumEntries / stripeCount); + } + } + + double sample(int blockX, int blockZ, HydrologyNaturalTerrainSampler sampler) { + long packed = RiverFootprint.pack(blockX, blockZ); + Stripe stripe = stripe(packed); + synchronized (stripe) { + double cached = stripe.heights.getAndMoveToFirst(packed); + if (!Double.isNaN(cached) || stripe.heights.containsKey(packed)) { + return cached; + } + double sampled = sampler.sampleLandHeight(blockX, blockZ); + stripe.put(packed, sampled); + return sampled; + } + } + + void seed(Long2DoubleMap samples) { + for (Long2DoubleMap.Entry entry : samples.long2DoubleEntrySet()) { + Stripe stripe = stripe(entry.getLongKey()); + synchronized (stripe) { + stripe.put(entry.getLongKey(), entry.getDoubleValue()); + } + } + } + + private Stripe stripe(long packed) { + return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)]; + } + + private static final class Stripe { + private final int maximumEntries; + private final Long2DoubleLinkedOpenHashMap heights; + + private Stripe(int maximumEntries) { + this.maximumEntries = maximumEntries; + heights = new Long2DoubleLinkedOpenHashMap(maximumEntries); + heights.defaultReturnValue(Double.NaN); + } + + private void put(long packed, double height) { + if (heights.size() >= maximumEntries && !heights.containsKey(packed)) { + heights.removeLastDouble(); + } + heights.putAndMoveToFirst(packed, height); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java index e946ded47..68535859d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java @@ -6,4 +6,9 @@ public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainS default HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) { return sampleBasis(blockX, blockZ); } + + default double sampleLandHeight(int blockX, int blockZ) { + HydrologyTerrainSample terrain = sampleBasisWithoutSlope(blockX, blockZ); + return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight(); + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java index 90d1ade4d..61c8dd76b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java @@ -11,6 +11,7 @@ import art.arcane.volmlib.util.noise.SimplexNoise; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap; import java.util.ArrayList; import java.util.Comparator; @@ -162,15 +163,22 @@ public final class HydrologyPlanner { this.geometrySampler = Objects.requireNonNull(geometrySampler, "geometrySampler"); this.minimumY = minimumY; this.caveViewFactory = Objects.requireNonNull(caveViewFactory, "caveViewFactory"); + HydrologyCacheBudget cacheBudget = HydrologyCacheBudget.runtime(settings.routing().regional().enabled()); this.routingContexts = Caffeine.newBuilder() - .maximumSize(ROUTING_CONTEXT_CACHE_SIZE) + .maximumWeight(cacheBudget.routingBytes()) + .weigher((HydrologyTileKey key, SourceRoutingContext context) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.routing(context), cacheBudget.routingBytes(), ROUTING_CONTEXT_CACHE_SIZE)) .build(); this.resolvingRoutingContexts = new ConcurrentHashMap<>(); this.resolvedOwners = Caffeine.newBuilder() - .maximumSize(RESOLVED_OWNER_CACHE_SIZE) + .maximumWeight(cacheBudget.ownerBytes()) + .weigher((HydrologyTileKey key, CrossTileResolvedOwner owner) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.owner(owner), cacheBudget.ownerBytes(), RESOLVED_OWNER_CACHE_SIZE)) .build(); this.refinedEdgeCache = Caffeine.newBuilder() - .maximumSize(REFINED_EDGE_CACHE_SIZE) + .maximumWeight(cacheBudget.edgeBytes()) + .weigher((RefinedEdgeKey key, List points) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.points(points), cacheBudget.edgeBytes(), REFINED_EDGE_CACHE_SIZE)) .build(); this.resolvingOwners = new ConcurrentHashMap<>(); this.planningSamples = new ThreadLocal<>(); @@ -304,7 +312,7 @@ public final class HydrologyPlanner { maximumCrossTileDependencyOffset(key), maximumCrossTileDependencyOwners(key) ); - CrossTileResolvedOwner resolved = resolveCrossTileOwner(key, context); + CrossTileResolvedOwner resolved = resolveCrossTileOwner(key, context, true); return new CrossTileResolution( resolved.draft(), resolved.observedRejections(), @@ -316,7 +324,8 @@ public final class HydrologyPlanner { CrossTileResolvedOwner resolveCrossTileOwner( HydrologyTileKey key, - CrossTileResolutionContext context + CrossTileResolutionContext context, + boolean retainFootprintCompiler ) { CrossTileResolvedOwner local = context.resolved(key); if (local != null) { @@ -347,7 +356,7 @@ public final class HydrologyPlanner { draft = crossTile.compileOwnerDraft( key, admission, - key.equals(context.root()) + retainFootprintCompiler ); } catch (RuntimeException | Error failure) { admission.primaryFailure = failure; @@ -379,7 +388,7 @@ public final class HydrologyPlanner { for (HydrologyTileKey candidateKey : candidateKeys) { HydrologyForkJoin.Task prepared = preparedOwners.get(candidateKey); CrossTileResolvedOwner owner = prepared == null - ? resolveCrossTileOwner(candidateKey, context) : prepared.await(); + ? resolveCrossTileOwner(candidateKey, context, false) : prepared.await(); context.remember(candidateKey, owner); resolved.add(owner); } @@ -408,7 +417,7 @@ public final class HydrologyPlanner { maximumCrossTileDependencyOffset(key), maximumCrossTileDependencyOwners(key) ); - return resolveCrossTileOwner(key, context); + return resolveCrossTileOwner(key, context, false); } static CrossTileResolvedOwner awaitResolvedOwner( @@ -578,6 +587,35 @@ public final class HydrologyPlanner { return terrain == null || terrain.ocean() ? null : terrain; } + double sampleLandHeight(int blockX, int blockZ) { + if (naturalSampler == null) { + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(blockX, blockZ); + return terrain == null ? Double.NaN : terrain.naturalHeight(); + } + PlanningSamples samples = planningSamples.get(); + if (samples == null) { + return naturalSampler.sampleLandHeight(blockX, blockZ); + } + long packed = RiverFootprint.pack(blockX, blockZ); + HydrologyTerrainSample terrain = samples.basis.get(packed); + if (terrain != null || samples.basis.containsKey(packed)) { + return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight(); + } + terrain = samples.basisWithoutSlope.get(packed); + if (terrain != null || samples.basisWithoutSlope.containsKey(packed)) { + return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight(); + } + double cached = samples.landHeights.get(packed); + if (!Double.isNaN(cached) || samples.landHeights.containsKey(packed)) { + return cached; + } + double sampled = samples.sharedLandHeights == null + ? naturalSampler.sampleLandHeight(blockX, blockZ) + : samples.sharedLandHeights.sample(blockX, blockZ, naturalSampler); + samples.landHeights.put(packed, sampled); + return sampled; + } + HydrologyTerrainSample sampleDetailed(int blockX, int blockZ) { PlanningSamples samples = planningSamples.get(); if (samples == null) { @@ -618,6 +656,11 @@ public final class HydrologyPlanner { this.sampler = Objects.requireNonNull(sampler, "sampler"); } + @Override + public boolean supportsSharedGridSamples() { + return true; + } + @Override public HydrologyTerrainSample[] sampleGrid(GridRequest request) { int minimumX = request.minimumX(); @@ -648,6 +691,8 @@ public final class HydrologyPlanner { static final class PlanningSamples { private final Long2ObjectOpenHashMap basis; private final Long2ObjectOpenHashMap basisWithoutSlope; + private final Long2DoubleOpenHashMap landHeights; + private HydrologyLandHeightCache sharedLandHeights; final Long2ObjectOpenHashMap detailed; private final Long2ObjectOpenHashMap classifications; private final HashMap geometry; @@ -661,8 +706,15 @@ public final class HydrologyPlanner { final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; PlanningSamples() { + this(null); + } + + PlanningSamples(HydrologyLandHeightCache sharedLandHeights) { + this.sharedLandHeights = sharedLandHeights; this.basis = new Long2ObjectOpenHashMap<>(); this.basisWithoutSlope = new Long2ObjectOpenHashMap<>(); + this.landHeights = new Long2DoubleOpenHashMap(); + this.landHeights.defaultReturnValue(Double.NaN); this.detailed = new Long2ObjectOpenHashMap<>(); this.classifications = new Long2ObjectOpenHashMap<>(); this.geometry = new HashMap<>(); @@ -675,6 +727,14 @@ public final class HydrologyPlanner { this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache(); this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); } + + HydrologyLandHeightCache fallbackLandHeights() { + if (sharedLandHeights == null) { + sharedLandHeights = new HydrologyLandHeightCache(); + sharedLandHeights.seed(landHeights); + } + return sharedLandHeights; + } } final class ColorRankedDraftAdmission implements CrossTileDraftAdmission, AutoCloseable { @@ -702,7 +762,8 @@ public final class HydrologyPlanner { @Override public void prepare() { if (ownerRank == 0 || settings.crossTileColorPeriod() != 2 || !sourcePlanner.hasRoutedSourceSearch() - || !(Thread.currentThread() instanceof ForkJoinWorkerThread worker)) { + || !(Thread.currentThread() instanceof ForkJoinWorkerThread worker) + || HydrologyPlanningAdmission.effectiveParallelism(worker.getPool().getParallelism()) <= 2) { return; } int radius = settings.crossTileColorPeriod() - 1; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmission.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmission.java new file mode 100644 index 000000000..6a50ff131 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmission.java @@ -0,0 +1,134 @@ +package art.arcane.iris.generation.hydrology; + +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; + +final class HydrologyPlanningAdmission { + private static final long BYTES_PER_ROOT = 2L * 1024L * 1024L * 1024L; + private static final int MAXIMUM_ROOTS = rootLimit( + Runtime.getRuntime().maxMemory(), Runtime.getRuntime().availableProcessors()); + private static final int MAXIMUM_TRIALS = trialLimit(Runtime.getRuntime().maxMemory(), MAXIMUM_ROOTS); + private static final HydrologyPlanningAdmission ROOTS = new HydrologyPlanningAdmission(MAXIMUM_ROOTS); + + private final Semaphore permits; + + HydrologyPlanningAdmission(int maximumRoots) { + if (maximumRoots < 1) { + throw new IllegalArgumentException("Maximum hydrology roots must be positive."); + } + permits = new Semaphore(maximumRoots, true); + } + + static Permit acquireRoot(BooleanSupplier cancelled) { + return ROOTS.acquire(cancelled); + } + + static int maximumRoots() { + return MAXIMUM_ROOTS; + } + + static int maximumTrials() { + return MAXIMUM_TRIALS; + } + + static int effectiveParallelism(int poolParallelism) { + return effectiveParallelism(poolParallelism, Runtime.getRuntime().availableProcessors()); + } + + static int effectiveParallelism(int poolParallelism, int availableProcessors) { + return Math.max(1, Math.min(poolParallelism, availableProcessors)); + } + + static int trialLimit(long maximumHeap, int maximumRoots) { + if (maximumRoots < 1) { + throw new IllegalArgumentException("Maximum hydrology roots must be positive."); + } + return (int) Math.clamp(maximumHeap / maximumRoots / (1024L * 1024L * 1024L), 1L, 16L); + } + + static int rootLimit(long maximumHeap, int processors) { + return (int) Math.max(1L, Math.min(Math.min(8L, processors), maximumHeap / BYTES_PER_ROOT)); + } + + Permit acquire(BooleanSupplier cancelled) { + AdmissionWait wait = new AdmissionWait(Objects.requireNonNull(cancelled)); + try { + ForkJoinPool.managedBlock(wait); + wait.requireActive(); + return new Permit(permits); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + if (wait.acquired) { + permits.release(); + } + throw new CancellationException("Hydrology planning admission interrupted."); + } catch (RuntimeException | Error failure) { + if (wait.acquired) { + permits.release(); + } + throw failure; + } + } + + static final class Permit implements AutoCloseable { + private final Semaphore permits; + private final AtomicBoolean released = new AtomicBoolean(); + + private Permit(Semaphore permits) { + this.permits = permits; + } + + @Override + public void close() { + if (released.compareAndSet(false, true)) { + permits.release(); + } + } + } + + private final class AdmissionWait implements ForkJoinPool.ManagedBlocker { + private final BooleanSupplier cancelled; + private boolean acquired; + + private AdmissionWait(BooleanSupplier cancelled) { + this.cancelled = cancelled; + } + + @Override + public boolean isReleasable() { + requireActive(); + if (!acquired) { + try { + acquired = permits.tryAcquire(0L, TimeUnit.NANOSECONDS); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new CancellationException("Hydrology planning admission interrupted."); + } + } + return acquired; + } + + @Override + public boolean block() throws InterruptedException { + requireActive(); + if (!acquired) { + acquired = permits.tryAcquire(100L, TimeUnit.MILLISECONDS); + } + return acquired; + } + + private void requireActive() { + if (Thread.currentThread().isInterrupted()) { + throw new CancellationException("Hydrology planning admission interrupted."); + } + if (cancelled.getAsBoolean()) { + throw new CancellationException("Hydrology tile cache is closed."); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java index 6e2eadf9d..88b25815a 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology; import java.util.ArrayList; +import java.util.BitSet; import java.util.Comparator; import java.util.List; import java.util.PriorityQueue; @@ -160,6 +161,20 @@ final class HydrologyRegionalGraph { } record Tree(Label[] selected, int[] accumulation, List outlets) { + int retainedLabelCount() { + BitSet retained = new BitSet(accumulation.length); + for (Label label : selected) { + for (Label current = label; current != null; current = current.downstream) { + int id = current.state.id(); + if (retained.get(id)) { + break; + } + retained.set(id); + } + } + return retained.cardinality(); + } + int root(int source) { return selected[source] == null ? -1 : selected[source].state.root(); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java index 41ad6bec0..fce51fed6 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java @@ -51,14 +51,18 @@ final class HydrologyRegionalPlanner { this.settings = planner.settings.routing().regional(); this.graph = new HydrologyRegionalGraph(planner); this.routes = new HydrologyRegionalRoute(planner); - int minimumWeight = Math.max(1, Math.ceilDiv(settings.maximumCachedStations(), settings.maximumCachedBasins())); + HydrologyCacheBudget cacheBudget = HydrologyCacheBudget.runtime(settings.enabled()); this.drafts = Caffeine.newBuilder() - .maximumWeight(settings.maximumCachedStations()) - .weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> Math.max(minimumWeight, network.stations())) + .maximumWeight(cacheBudget.regionalDraftBytes()) + .weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.regionalNetwork(network), cacheBudget.regionalDraftBytes(), + settings.maximumCachedBasins(), network.stations(), settings.maximumCachedStations())) .build(); this.coarsePlans = Caffeine.newBuilder() - .maximumWeight(settings.maximumCachedStations()) - .weigher((HydrologyTileKey key, HydrologyRegionalCoarsePlan plan) -> Math.max(minimumWeight, plan.weight())) + .maximumWeight(cacheBudget.regionalCoarseBytes()) + .weigher((HydrologyTileKey key, HydrologyRegionalCoarsePlan plan) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.regionalCoarse(plan), cacheBudget.regionalCoarseBytes(), + settings.maximumCachedBasins(), plan.weight(), settings.maximumCachedStations())) .build(); } @@ -347,8 +351,13 @@ final class HydrologyRegionalPlanner { if (tasks.isEmpty()) { break; } - List completed = HydrologyForkJoin.invokeAll(tasks, - IrisPlatforms.isBound() ? MultiBurst.hydrology : null); + ArrayList completed = new ArrayList<>(tasks.size()); + int batchSize = HydrologyPlanningAdmission.maximumTrials(); + for (int start = 0; start < tasks.size(); start += batchSize) { + int end = Math.min(tasks.size(), start + batchSize); + completed.addAll(HydrologyForkJoin.invokeAll(tasks.subList(start, end), + IrisPlatforms.isBound() ? MultiBurst.hydrology : null)); + } for (SourceTrial trial : completed) { if (accepted.size() >= settings.maximumTrunks()) { break; @@ -642,8 +651,9 @@ final class HydrologyRegionalPlanner { int[] excavation = new int[centerline.size()]; HydrologyRegionalConnectivity connectivity = new HydrologyRegionalConnectivity(); HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forCourse(planner.settings, this::sample, course); + SurfaceFootprintCompiler.PreparedCourse prepared = compiler.prepare(course); for (SurfaceBounds bounds : courseWindows(course)) { - SurfaceFootprint footprint = compiler.compile(course, bounds); + SurfaceFootprint footprint = compiler.compile(prepared, bounds); if (!footprint.accepted()) { diagnostic(diagnostics, course.id(), source, coastal, footprint.rejection(), footprint.rejectionDetail()); return false; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java index 0f1028f98..e34408ce9 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java @@ -23,16 +23,18 @@ final class HydrologyRegionalTerrainRefiner { private final HydrologyPlanner planner; private final HydrologyRegionalHydraulics hydraulics; + private final HydrologyCacheBudget cacheBudget; private volatile CacheState caches; HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) { this.planner = planner; this.hydraulics = new HydrologyRegionalHydraulics(planner.settings); - this.caches = new CacheState(); + this.cacheBudget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled()); + this.caches = new CacheState(cacheBudget); } void clear() { - caches = new CacheState(); + caches = new CacheState(cacheBudget); } HydrologyTerrainSample sample(int x, int z) { @@ -459,10 +461,23 @@ final class HydrologyRegionalTerrainRefiner { } private static final class CacheState { - private final Cache edges = Caffeine.newBuilder().maximumSize(128).build(); + private final Cache edges; private final ConcurrentHashMap> loading = new ConcurrentHashMap<>(); - private final Cache terrain = Caffeine.newBuilder().maximumSize(Math.max(MAXIMUM_SAMPLES, - Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))).build(); + private final Cache terrain; + + private CacheState(HydrologyCacheBudget budget) { + edges = Caffeine.newBuilder() + .maximumWeight(budget.regionalReachBytes()) + .weigher((Edge edge, Reach reach) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.points(reach.points()), budget.regionalReachBytes(), 128)) + .build(); + terrain = Caffeine.newBuilder() + .maximumWeight(budget.regionalTerrainBytes()) + .weigher((Long key, Terrain terrain) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.terrain(terrain.sample()), budget.regionalTerrainBytes(), + (int) Math.max(MAXIMUM_SAMPLES, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L)))) + .build(); + } } private record Offer(HydrologyPoint point, double cost, int availableHead) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java index 801f017f0..fc1e96c48 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java @@ -657,11 +657,11 @@ final class HydrologyRouteGeometry { int lowest = center.naturalHeight(); for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) { for (double direction = -1D; direction <= 1D; direction += 2D) { - HydrologyTerrainSample side = planner.sampleLandBasisWithoutSlope( + double side = planner.sampleLandHeight( (int) StrictMath.round(point.x() + normalX * distance * direction), (int) StrictMath.round(point.z() + normalZ * distance * direction)); - if (side != null) { - lowest = Math.min(lowest, side.naturalHeight()); + if (!Double.isNaN(side)) { + lowest = Math.min(lowest, (int) side); } } } @@ -819,6 +819,7 @@ final class HydrologyRouteGeometry { Arrays.fill(row, Double.NaN); } } + CurvatureWorkspace workspace = new CurvatureWorkspace(layers); while (true) { CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute( layers, @@ -827,7 +828,8 @@ final class HydrologyRouteGeometry { turnCost, localPenalties, transitionCosts, - turnCosts + turnCosts, + workspace ); if (selection.route().length == 0) { return selection.route(); @@ -852,17 +854,17 @@ final class HydrologyRouteGeometry { double turnCost, double[][] localPenalties, double[][][] transitionCosts, - double[][][][] turnCosts + double[][][][] turnCosts, + CurvatureWorkspace workspace ) { int layerCount = layers.size(); - double[][][] costs = new double[layerCount][][]; - int[][][] predecessors = new int[layerCount][][]; + double[][] previousCosts = workspace.firstCosts; + double[][] currentCosts = workspace.secondCosts; + int[][][] predecessors = workspace.predecessors; List firstLayer = layers.getFirst(); List secondLayer = layers.get(1); - costs[1] = new double[firstLayer.size()][secondLayer.size()]; - predecessors[1] = new int[firstLayer.size()][secondLayer.size()]; for (int firstIndex = 0; firstIndex < firstLayer.size(); firstIndex++) { - Arrays.fill(costs[1][firstIndex], Double.POSITIVE_INFINITY); + Arrays.fill(previousCosts[firstIndex], Double.POSITIVE_INFINITY); Arrays.fill(predecessors[1][firstIndex], -1); for (int secondIndex = 0; secondIndex < secondLayer.size(); secondIndex++) { RouteCandidate first = firstLayer.get(firstIndex); @@ -871,10 +873,14 @@ final class HydrologyRouteGeometry { first, second, targetStepLength, - transitionCosts[1][firstIndex][secondIndex] + transitionCosts[1][firstIndex][secondIndex], + workspace, + 1, + firstIndex, + secondIndex ); if (Double.isFinite(transition)) { - costs[1][firstIndex][secondIndex] = first.localScore() + previousCosts[firstIndex][secondIndex] = first.localScore() + second.localScore() + resolvedLocalPenalty(localPenalties[1][secondIndex]) + transition; @@ -885,13 +891,11 @@ final class HydrologyRouteGeometry { List beforeLayer = layers.get(layerIndex - 2); List previousLayer = layers.get(layerIndex - 1); List currentLayer = layers.get(layerIndex); - costs[layerIndex] = new double[previousLayer.size()][currentLayer.size()]; - predecessors[layerIndex] = new int[previousLayer.size()][currentLayer.size()]; if (turnCosts[layerIndex] == null) { turnCosts[layerIndex] = new double[previousLayer.size()][currentLayer.size()][]; } for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) { - Arrays.fill(costs[layerIndex][previousIndex], Double.POSITIVE_INFINITY); + Arrays.fill(currentCosts[previousIndex], Double.POSITIVE_INFINITY); Arrays.fill(predecessors[layerIndex][previousIndex], -1); RouteCandidate previous = previousLayer.get(previousIndex); for (int currentIndex = 0; currentIndex < currentLayer.size(); currentIndex++) { @@ -900,14 +904,18 @@ final class HydrologyRouteGeometry { previous, current, targetStepLength, - transitionCosts[layerIndex][previousIndex][currentIndex] + transitionCosts[layerIndex][previousIndex][currentIndex], + workspace, + layerIndex, + previousIndex, + currentIndex ); if (!Double.isFinite(transition)) { continue; } double localPenalty = resolvedLocalPenalty(localPenalties[layerIndex][currentIndex]); for (int beforeIndex = 0; beforeIndex < beforeLayer.size(); beforeIndex++) { - double previousCost = costs[layerIndex - 1][beforeIndex][previousIndex]; + double previousCost = previousCosts[beforeIndex][previousIndex]; if (!Double.isFinite(previousCost)) { continue; } @@ -937,24 +945,27 @@ final class HydrologyRouteGeometry { + localPenalty + transition + turnPenalty; - if (cost < costs[layerIndex][previousIndex][currentIndex]) { - costs[layerIndex][previousIndex][currentIndex] = cost; + if (cost < currentCosts[previousIndex][currentIndex]) { + currentCosts[previousIndex][currentIndex] = cost; predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex; } } } } + double[][] completedCosts = currentCosts; + currentCosts = previousCosts; + previousCosts = completedCosts; } int lastLayerIndex = layerCount - 1; int selectedPreviousIndex = -1; int selectedCurrentIndex = -1; double selectedCost = Double.POSITIVE_INFINITY; - for (int previousIndex = 0; previousIndex < costs[lastLayerIndex].length; previousIndex++) { + for (int previousIndex = 0; previousIndex < layers.get(lastLayerIndex - 1).size(); previousIndex++) { for (int currentIndex = 0; - currentIndex < costs[lastLayerIndex][previousIndex].length; + currentIndex < layers.get(lastLayerIndex).size(); currentIndex++) { - if (costs[lastLayerIndex][previousIndex][currentIndex] < selectedCost) { - selectedCost = costs[lastLayerIndex][previousIndex][currentIndex]; + if (previousCosts[previousIndex][currentIndex] < selectedCost) { + selectedCost = previousCosts[previousIndex][currentIndex]; selectedPreviousIndex = previousIndex; selectedCurrentIndex = currentIndex; } @@ -1048,11 +1059,24 @@ final class HydrologyRouteGeometry { RouteCandidate previous, RouteCandidate current, double targetStepLength, - double exactCost + double exactCost, + CurvatureWorkspace workspace, + int layerIndex, + int previousIndex, + int currentIndex ) { - return Double.isNaN(exactCost) - ? planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength) - : exactCost; + if (!Double.isNaN(exactCost)) { + return exactCost; + } + long heights = ((long) previous.point().y() << 32) | (current.point().y() & 0xffffffffL); + if (!workspace.geometryKnown[layerIndex][previousIndex][currentIndex] + || workspace.geometryHeights[layerIndex][previousIndex][currentIndex] != heights) { + workspace.geometryCosts[layerIndex][previousIndex][currentIndex] = + planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength); + workspace.geometryHeights[layerIndex][previousIndex][currentIndex] = heights; + workspace.geometryKnown[layerIndex][previousIndex][currentIndex] = true; + } + return workspace.geometryCosts[layerIndex][previousIndex][currentIndex]; } double curvatureCost(double turn, double maximumTurn, double turnCost) { @@ -1139,6 +1163,36 @@ final class HydrologyRouteGeometry { return penalty; } + static final class CurvatureWorkspace { + private final double[][] firstCosts; + private final double[][] secondCosts; + private final int[][][] predecessors; + private final double[][][] geometryCosts; + private final long[][][] geometryHeights; + private final boolean[][][] geometryKnown; + + CurvatureWorkspace(List> layers) { + int maximumCandidates = 0; + for (List layer : layers) { + maximumCandidates = Math.max(maximumCandidates, layer.size()); + } + firstCosts = new double[maximumCandidates][maximumCandidates]; + secondCosts = new double[maximumCandidates][maximumCandidates]; + predecessors = new int[layers.size()][][]; + geometryCosts = new double[layers.size()][][]; + geometryHeights = new long[layers.size()][][]; + geometryKnown = new boolean[layers.size()][][]; + for (int layer = 1; layer < layers.size(); layer++) { + int previousSize = layers.get(layer - 1).size(); + int currentSize = layers.get(layer).size(); + predecessors[layer] = new int[previousSize][currentSize]; + geometryCosts[layer] = new double[previousSize][currentSize]; + geometryHeights[layer] = new long[previousSize][currentSize]; + geometryKnown[layer] = new boolean[previousSize][currentSize]; + } + } + } + record CurvatureRouteSelection(RouteCandidate[] route, int[] indices) { private static CurvatureRouteSelection empty() { return new CurvatureRouteSelection(new RouteCandidate[0], new int[0]); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java index 4bd867b87..191d12597 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java @@ -63,13 +63,13 @@ final class HydrologyRoutePath { if (offsetX == 0 && offsetZ == 0) { return false; } - HydrologyTerrainSample before = planner.sampleLandBasisWithoutSlope(point.x() - offsetX, point.z() - offsetZ); - HydrologyTerrainSample after = planner.sampleLandBasisWithoutSlope(point.x() + offsetX, point.z() + offsetZ); + double before = planner.sampleLandHeight(point.x() - offsetX, point.z() - offsetZ); + double after = planner.sampleLandHeight(point.x() + offsetX, point.z() + offsetZ); int threshold = planner.settings.hydraulics().waterfallMinimumDrop(); - return before != null - && after != null - && before.naturalHeight() - point.y() >= threshold - && after.naturalHeight() - point.y() >= threshold; + return !Double.isNaN(before) + && !Double.isNaN(after) + && (int) before - point.y() >= threshold + && (int) after - point.y() >= threshold; } List smoothTerrainRoute(List route) { @@ -220,12 +220,12 @@ final class HydrologyRoutePath { boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) { List crossing = planner.segments.rasterLine(start, end); - HydrologyTerrainSample startTerrain = planner.sampleLandBasisWithoutSlope(start.x(), start.z()); - HydrologyTerrainSample endTerrain = planner.sampleLandBasisWithoutSlope(end.x(), end.z()); - if (startTerrain == null || endTerrain == null) { + double startHeight = planner.sampleLandHeight(start.x(), start.z()); + double endHeight = planner.sampleLandHeight(end.x(), end.z()); + if (Double.isNaN(startHeight) || Double.isNaN(endHeight)) { return false; } - int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight()); + int boundaryHeight = Math.min((int) startHeight, (int) endHeight); int threshold = planner.settings.hydraulics().waterfallMinimumDrop(); for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { HydrologyPoint point = crossing.get(pointIndex); @@ -244,17 +244,17 @@ final class HydrologyRoutePath { if (crossing.size() < 3) { return false; } - HydrologyTerrainSample startTerrain = planner.sampleLandBasisWithoutSlope(start.x(), start.z()); - HydrologyTerrainSample endTerrain = planner.sampleLandBasisWithoutSlope(end.x(), end.z()); - if (startTerrain == null || endTerrain == null) { + double startHeight = planner.sampleLandHeight(start.x(), start.z()); + double endHeight = planner.sampleLandHeight(end.x(), end.z()); + if (Double.isNaN(startHeight) || Double.isNaN(endHeight)) { return true; } - int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight()); + int boundaryHeight = Math.min((int) startHeight, (int) endHeight); int threshold = planner.settings.hydraulics().waterfallMinimumDrop(); for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { HydrologyPoint point = crossing.get(pointIndex); - HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(point.x(), point.z()); - if (terrain == null || boundaryHeight - terrain.naturalHeight() >= threshold) { + double height = planner.sampleLandHeight(point.x(), point.z()); + if (Double.isNaN(height) || boundaryHeight - (int) height >= threshold) { return true; } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingTerrainSampler.java index 0e6435dd2..2bb8fbc16 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingTerrainSampler.java @@ -5,6 +5,10 @@ public interface HydrologyRoutingTerrainSampler { NaturalClassification classifyNatural(int blockX, int blockZ); + default boolean supportsSharedGridSamples() { + return false; + } + enum NaturalClassification { LAND, OCEAN, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java index d434fd45f..c6d2ec123 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java @@ -696,8 +696,15 @@ final class HydrologySourcePlanner { } int x = (int) neighborX; int z = (int) neighborZ; - SourceRoutingContext context = sourceContext(x, z, routingContexts); - HydrologyGridNode neighbor = context.grid().nodeAtWorld(x, z); + HydrologyTileKey owner = HydrologyTileKey.fromBlock(x, z, planner.settings.routing().tileSize()); + SourceRoutingContext context = routingContexts.get(owner); + HydrologyGridNode neighbor = context == null + ? sampledSourceNode(x, z, routingContexts) + : context.grid().nodeAtWorld(x, z); + if (neighbor == null) { + context = sourceContext(x, z, routingContexts); + neighbor = context.grid().nodeAtWorld(x, z); + } if (neighbor == null) { throw new IllegalStateException("Source coordinate is absent from its owner routing lattice."); } @@ -711,11 +718,18 @@ final class HydrologySourcePlanner { if (!rawSourceEligible(terrain, sourceSettings, surface)) { continue; } - if (!routeViable(neighbor, context, surface)) { + SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface); + if (compareSourcePriority(neighborPriority, candidatePriority) <= 0) { continue; } - SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface); - if (compareSourcePriority(neighborPriority, candidatePriority) > 0) { + if (context == null) { + context = sourceContext(x, z, routingContexts); + neighbor = context.grid().nodeAtWorld(x, z); + if (neighbor == null) { + throw new IllegalStateException("Source coordinate is absent from its owner routing lattice."); + } + } + if (routeViable(neighbor, context, surface)) { contenders.add(new SourceContender(neighbor, neighborPriority)); } } @@ -729,6 +743,23 @@ final class HydrologySourcePlanner { return ordered; } + private HydrologyGridNode sampledSourceNode(int x, int z, + Map routingContexts) { + if (!planner.routingSampler.supportsSharedGridSamples()) { + return null; + } + for (SourceRoutingContext context : routingContexts.values()) { + if (context.grid().spacing() != planner.settings.routing().sampleSpacing()) { + continue; + } + HydrologyGridNode node = context.grid().nodeAtWorld(x, z); + if (node != null) { + return node; + } + } + return null; + } + private SourceRoutingContext sourceContext( int x, int z, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java index e016ecd47..0e66ed068 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java @@ -705,11 +705,13 @@ final class HydrologySurfaceCoursePlanner { int head, int maximumBankRise ) { - HydrologyTerrainSample bank = surfaceBankTerrain(point, tangent, signedDistance); - if (bank == null) { + int bankX = (int) StrictMath.round(point.x() - tangent.z() * signedDistance); + int bankZ = (int) StrictMath.round(point.z() + tangent.x() * signedDistance); + double bankHeight = planner.sampleLandHeight(bankX, bankZ); + if (Double.isNaN(bankHeight)) { return 1.0E12D; } - int rise = bank.naturalHeight() - head; + int rise = (int) bankHeight - head; int shortfall = HydrologyRouteGeometry.SURFACE_MINIMUM_NATURAL_BANK_RISE - rise; if (shortfall > 0) { return (double) shortfall * shortfall * 4096D; @@ -922,17 +924,6 @@ final class HydrologySurfaceCoursePlanner { return pointX * pointX + pointZ * pointZ; } - HydrologyTerrainSample surfaceBankTerrain( - HydrologyPoint point, - RouteDirection tangent, - int signedDistance - ) { - HydrologyPoint bankPoint = surfaceBankPoint(point, tangent, signedDistance); - int bankX = bankPoint.x(); - int bankZ = bankPoint.z(); - return planner.sampleLandBasisWithoutSlope(bankX, bankZ); - } - HydrologyPoint surfaceBankPoint(HydrologyPoint point, RouteDirection tangent, int signedDistance) { return new HydrologyPoint( (int) StrictMath.round(point.x() - tangent.z() * signedDistance), diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index bf3dd7827..79838c84d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -36,7 +37,9 @@ public final class HydrologyTileCache implements AutoCloseable { private static final int CHUNK_SIZE = 16; private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE; private static final Cache SHARED_TILES = Caffeine.newBuilder() - .maximumSize(MAXIMUM_SHARED_ENTRIES) + .maximumWeight(HydrologyCacheBudget.sharedStudioBytes()) + .weigher((SharedTileKey key, HydrologyTile tile) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.tile(tile), HydrologyCacheBudget.sharedStudioBytes(), MAXIMUM_SHARED_ENTRIES)) .expireAfterAccess(10, TimeUnit.MINUTES) .build(); @@ -51,6 +54,7 @@ public final class HydrologyTileCache implements AutoCloseable { private final Executor prefetchExecutor; private final ConcurrentLinkedQueue prefetchQueue; private final Set queuedPrefetches; + private final LinkedHashSet queriedChunks; private final Set> demandedPlans; private final AtomicBoolean prefetchActive; private final ConcurrentHashMap, CompletableFuture> planning; @@ -117,16 +121,24 @@ public final class HydrologyTileCache implements AutoCloseable { this.maximumEntries = maximumEntries; this.prefetchQueue = new ConcurrentLinkedQueue<>(); this.queuedPrefetches = ConcurrentHashMap.newKeySet(); + this.queriedChunks = new LinkedHashSet<>(); this.demandedPlans = new HashSet<>(); this.prefetchActive = new AtomicBoolean(); + HydrologyCacheBudget budget = HydrologyCacheBudget.runtime(); this.tiles = Caffeine.newBuilder() - .maximumSize(maximumEntries) + .maximumWeight(budget.tileBytes()) + .weigher((HydrologyTileKey key, HydrologyTile tile) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.tile(tile), budget.tileBytes(), maximumEntries)) .build(); this.composedChunks = Caffeine.newBuilder() - .maximumSize(MAXIMUM_COMPOSED_CHUNKS) + .maximumWeight(budget.columnBytes()) + .weigher((Long key, ChunkColumns columns) -> HydrologyCacheWeights.bounded( + columns.estimatedBytes(), budget.columnBytes(), MAXIMUM_COMPOSED_CHUNKS)) .build(); this.diagnostics = Caffeine.newBuilder() - .maximumSize(maximumEntries) + .maximumWeight(budget.diagnosticBytes()) + .weigher((HydrologyTileKey key, List candidates) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.diagnostics(candidates), budget.diagnosticBytes(), maximumEntries)) .build(); this.cacheEpoch = new AtomicLong(); this.closed = new AtomicBoolean(); @@ -198,11 +210,8 @@ public final class HydrologyTileCache implements AutoCloseable { } try { present = diagnostics.getIfPresent(key); - if (present == null) { - prepareTerrain(); - } List result = present == null - ? List.copyOf(planner.diagnosticCandidates(planInline(key, loadKey.epoch()))) : present; + ? planDiagnostics(key, loadKey.epoch()) : present; synchronized (publicationLock) { if (!closed.get() && cacheEpoch.get() == loadKey.epoch()) { diagnostics.put(key, result); @@ -218,6 +227,14 @@ public final class HydrologyTileCache implements AutoCloseable { } } + private List planDiagnostics(HydrologyTileKey key, long epoch) { + HydrologyTile tile = planInline(key, epoch); + try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) { + prepareTerrain(); + return List.copyOf(planner.diagnosticCandidates(tile)); + } + } + private CompletableFuture> diagnoseAsync(CacheLoadKey loadKey) { List present = diagnostics.getIfPresent(loadKey.key()); if (present != null) { @@ -329,6 +346,13 @@ public final class HydrologyTileCache implements AutoCloseable { SHARED_TILES.invalidate(sharedKey); } StudioHydrologyTileStore store = persistentStore; + try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) { + return planAdmitted(key, planningEpoch, sharedKey, store); + } + } + + private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch, + SharedTileKey sharedKey, StudioHydrologyTileStore store) { if (store != null && persistentStudioKey(key)) { HydrologyTile persisted = store.load(key).orElse(null); if (persisted != null) { @@ -360,7 +384,7 @@ public final class HydrologyTileCache implements AutoCloseable { } return planned; } catch (RuntimeException failure) { - if (interrupted(failure)) { + if (failure instanceof CancellationException || interrupted(failure)) { throw failure; } IrisLogging.error("Hydrology tile " + key.tileX() + "," + key.tileZ() @@ -449,13 +473,24 @@ public final class HydrologyTileCache implements AutoCloseable { continue; } missing = true; - if (prefetchExecutor != null) { - prefetch(key); - } + break; + } + if (missing) { + requestChunk(chunkX, chunkZ); } return missing; } + private void requestChunk(int chunkX, int chunkZ) { + synchronized (prefetchQueue) { + if (prefetchExecutor == null || closed.get() || queriedChunks.size() >= maximumEntries) { + return; + } + queriedChunks.add(new ChunkCoordinate(chunkX, chunkZ)); + } + pumpPrefetch(); + } + /** Whether the calling thread must never wait for a cold hydrology plan. */ private boolean waitsForbidden() { return waitingForbidden != null && waitingForbidden.getAsBoolean(); @@ -473,6 +508,7 @@ public final class HydrologyTileCache implements AutoCloseable { } public void clear() { + ArrayList> cancelledQueries = new ArrayList<>(); synchronized (prefetchQueue) { synchronized (publicationLock) { cacheEpoch.incrementAndGet(); @@ -480,13 +516,22 @@ public final class HydrologyTileCache implements AutoCloseable { tiles.invalidateAll(); composedChunks.invalidateAll(); diagnostics.invalidateAll(); + composing.forEach((key, load) -> { + if (load.owner() == null && composing.remove(key, load)) { + cancelledQueries.add(load.future()); + } + }); } prefetchQueue.clear(); queuedPrefetches.clear(); + queriedChunks.clear(); demandedPlans.clear(); demandBatches = 0; } localChunkColumns.remove(); + for (CompletableFuture future : cancelledQueries) { + future.completeExceptionally(new CancellationException("Hydrology query was invalidated.")); + } } public int size() { @@ -546,7 +591,11 @@ public final class HydrologyTileCache implements AutoCloseable { collectActiveLoad(active, load); } for (PendingLoad load : composing.values()) { - collectActiveLoad(active, load); + if (load.owner() == null) { + queued.add(load.future()); + } else { + collectActiveLoad(active, load); + } } for (PendingLoad> load : loadingDiagnostics.values()) { collectActiveLoad(active, load); @@ -620,13 +669,29 @@ public final class HydrologyTileCache implements AutoCloseable { return local.columns(); } long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ)); - if (composedChunks.getIfPresent(packedChunk) == null - && waitsForbidden() - && requestUnplanned(chunkX, chunkZ)) { - // A thread that must never wait (the server thread answering a query) gets a riverless - // answer while the tiles it would have waited for are planned on the prefetch executor. - // The empty result is never cached, so the next query composes the real columns. - return ChunkColumns.empty(chunkX, chunkZ); + if (waitsForbidden()) { + ChunkColumns present = composedChunks.getIfPresent(packedChunk); + if (present != null) { + return present; + } + ArrayList keys = relevantKeys(chunkX, chunkZ); + ArrayList snapshots = new ArrayList<>(keys.size()); + for (HydrologyTileKey key : keys) { + HydrologyTile tile = tiles.getIfPresent(key); + if (tile == null) { + requestChunk(chunkX, chunkZ); + return ChunkColumns.empty(chunkX, chunkZ); + } + snapshots.add(tile); + } + ChunkColumns columns = composeChunkColumns(chunkX, chunkZ, keys, snapshots); + synchronized (publicationLock) { + if (!closed.get() && cacheEpoch.get() == epoch) { + composedChunks.put(packedChunk, columns); + } + } + localChunkColumns.set(new LocalChunkColumns(epoch, columns)); + return columns; } ChunkColumns columns = loadChunkColumns(new CacheLoadKey<>(epoch, packedChunk), chunkX, chunkZ); localChunkColumns.set(new LocalChunkColumns(epoch, columns)); @@ -642,16 +707,29 @@ public final class HydrologyTileCache implements AutoCloseable { } PendingLoad owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>()); PendingLoad existing; + boolean claimedQuery = false; synchronized (publicationLock) { requireOpen(); existing = composing.putIfAbsent(loadKey, owned); + if (existing != null && existing.owner() == null && !existing.future().isDone()) { + PendingLoad claimed = new PendingLoad<>(Thread.currentThread(), existing.future()); + if (composing.replace(loadKey, existing, claimed)) { + owned = claimed; + existing = null; + claimedQuery = true; + } + } } if (existing != null) { return awaitLoad(existing); } try { present = composedChunks.getIfPresent(packedChunk); - ChunkColumns columns = present == null ? composeChunkColumns(chunkX, chunkZ) : present; + ChunkColumns columns = present; + if (columns == null) { + columns = claimedQuery ? composeChunkColumnsInline(chunkX, chunkZ, epoch) + : composeChunkColumns(chunkX, chunkZ); + } synchronized (publicationLock) { if (!closed.get() && cacheEpoch.get() == epoch) { composedChunks.put(packedChunk, columns); @@ -689,14 +767,28 @@ public final class HydrologyTileCache implements AutoCloseable { } private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) { + ArrayList relevantKeys = relevantKeys(chunkX, chunkZ); + return composeChunkColumns(chunkX, chunkZ, relevantKeys, tiles(relevantKeys)); + } + + private ChunkColumns composeChunkColumnsInline(int chunkX, int chunkZ, long epoch) { + ArrayList keys = relevantKeys(chunkX, chunkZ); + ArrayList snapshots = new ArrayList<>(keys.size()); + for (HydrologyTileKey key : keys) { + snapshots.add(planInline(key, epoch)); + } + return composeChunkColumns(chunkX, chunkZ, keys, snapshots); + } + + private ChunkColumns composeChunkColumns(int chunkX, int chunkZ, + List relevantKeys, + List relevantTiles) { int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); - ArrayList relevantKeys = relevantKeys(chunkX, chunkZ); int minimumTileX = relevantKeys.getFirst().tileX(); int maximumTileX = relevantKeys.getLast().tileX(); int minimumTileZ = relevantKeys.getFirst().tileZ(); int maximumTileZ = relevantKeys.getLast().tileZ(); - List relevantTiles = tiles(relevantKeys); prefetchNeighbours(minimumTileX, maximumTileX, minimumTileZ, maximumTileZ); HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT]; for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { @@ -823,12 +915,22 @@ public final class HydrologyTileCache implements AutoCloseable { } private int speculativeEntryCount() { + long entries = tiles.estimatedSize(); + long budget = HydrologyCacheBudget.runtime().tileBytes(); + long weight = tiles.policy().eviction().orElseThrow().weightedSize().orElse(0L); + long available = speculativeCapacity(entries, budget, weight); return Math.toIntExact(Math.min( maximumEntries, - tiles.estimatedSize() + planning.size() + queuedPrefetches.size() + Math.max(entries, maximumEntries - available) + planning.size() + queuedPrefetches.size() )); } + private long speculativeCapacity(long entries, long budget, long weight) { + long expectedWeight = Math.max(Math.ceilDiv(budget, maximumEntries), + Math.ceilDiv(weight, Math.max(1L, entries))); + return Math.max(0L, budget - weight) / expectedWeight; + } + private void pumpPrefetch() { synchronized (prefetchQueue) { if (prefetchExecutor == null || closed.get() || demandBatches != 0 || !demandedPlans.isEmpty() @@ -840,23 +942,115 @@ public final class HydrologyTileCache implements AutoCloseable { } private void scheduleNextPrefetch() { - CacheLoadKey loadKey; + CacheLoadKey loadKey = null; + ChunkCoordinate chunk; + long epoch; synchronized (prefetchQueue) { if (closed.get() || demandBatches != 0 || !demandedPlans.isEmpty()) { prefetchActive.set(false); return; } - HydrologyTileKey key = pollPrefetch(); - if (key == null) { - prefetchActive.set(false); - return; + epoch = cacheEpoch.get(); + chunk = pollQuery(); + if (chunk != null) { + demandBatches++; + } else { + HydrologyTileKey key = pollPrefetch(); + if (key == null) { + prefetchActive.set(false); + return; + } + loadKey = new CacheLoadKey<>(epoch, key); } - loadKey = new CacheLoadKey<>(cacheEpoch.get(), key); } - planAsync(loadKey).whenComplete((tile, failure) -> scheduleNextPrefetch()); + if (chunk != null) { + composeAsync(chunk, epoch).whenComplete((columns, failure) -> { + synchronized (prefetchQueue) { + if (epoch == cacheEpoch.get()) { + queriedChunks.remove(chunk); + } + } + finishDemandBatch(epoch); + scheduleNextPrefetch(); + }); + } else { + planAsync(loadKey).whenComplete((tile, failure) -> scheduleNextPrefetch()); + } + } + + private ChunkCoordinate pollQuery() { + while (!queriedChunks.isEmpty()) { + ChunkCoordinate chunk = queriedChunks.getFirst(); + if (composedChunks.getIfPresent(chunk.packed()) == null) { + return chunk; + } + queriedChunks.removeFirst(); + } + return null; + } + + private CompletableFuture composeAsync(ChunkCoordinate chunk, long epoch) { + CacheLoadKey loadKey = new CacheLoadKey<>(epoch, chunk.packed()); + PendingLoad queued = new PendingLoad<>(null, new CompletableFuture<>()); + synchronized (publicationLock) { + if (closed.get() || epoch != cacheEpoch.get()) { + return CompletableFuture.failedFuture(new CancellationException("Hydrology query was invalidated.")); + } + PendingLoad existing = composing.putIfAbsent(loadKey, queued); + if (existing != null) { + return existing.future(); + } + } + try { + prefetchExecutor.execute(() -> runComposition(loadKey, chunk, queued)); + } catch (RuntimeException | Error rejected) { + if (composing.remove(loadKey, queued)) { + queued.future().completeExceptionally(rejected); + } + } + return queued.future(); + } + + private void runComposition(CacheLoadKey loadKey, ChunkCoordinate chunk, + PendingLoad queued) { + PendingLoad active = new PendingLoad<>(Thread.currentThread(), queued.future()); + boolean started; + synchronized (publicationLock) { + started = !closed.get() && loadKey.epoch() == cacheEpoch.get() && !queued.future().isDone() + && composing.replace(loadKey, queued, active); + } + if (!started) { + if (composing.remove(loadKey, queued)) { + queued.future().completeExceptionally(new CancellationException("Hydrology query was invalidated.")); + } + return; + } + try { + ChunkColumns columns = composeChunkColumnsInline(chunk.x(), chunk.z(), loadKey.epoch()); + synchronized (publicationLock) { + if (!closed.get() && cacheEpoch.get() == loadKey.epoch()) { + composedChunks.put(loadKey.key(), columns); + } + } + active.future().complete(columns); + } catch (Throwable failure) { + active.future().completeExceptionally(failure); + if (!(failure instanceof CancellationException)) { + IrisLogging.reportError(failure); + } + } finally { + composing.remove(loadKey, active); + } } private HydrologyTileKey pollPrefetch() { + tiles.cleanUp(); + long weight = tiles.policy().eviction().orElseThrow().weightedSize().orElse(0L); + if (speculativeCapacity(tiles.estimatedSize(), HydrologyCacheBudget.runtime().tileBytes(), weight) == 0L) { + prefetchQueue.clear(); + queuedPrefetches.clear(); + return null; + } HydrologyTileKey key; while ((key = prefetchQueue.poll()) != null) { if (!queuedPrefetches.remove(key)) { @@ -905,7 +1099,7 @@ public final class HydrologyTileCache implements AutoCloseable { } return List.of(loaded); } - int batch = Math.max(1, maximumEntries / 2); + int batch = Math.max(1, Math.min(maximumEntries / 2, HydrologyPlanningAdmission.maximumRoots())); ArrayList> futures = new ArrayList<>(Math.min(batch, loaded.length)); for (int start = 0; start < loaded.length; start += batch) { int end = Math.min(loaded.length, start + batch); @@ -1129,6 +1323,16 @@ public final class HydrologyTileCache implements AutoCloseable { } private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) { + private long estimatedBytes() { + long bytes = 64L + 8L * columns.length; + for (HydrologyColumnSample column : columns) { + if (column != null) { + bytes += HydrologyCacheWeights.column(column); + } + } + return bytes; + } + /** A chunk with no hydrology at all, the answer for a caller that may not wait for its plan. */ private static ChunkColumns empty(int chunkX, int chunkZ) { return new ChunkColumns(chunkX, chunkZ, new HydrologyColumnSample[CHUNK_COLUMN_COUNT]); @@ -1144,6 +1348,12 @@ public final class HydrologyTileCache implements AutoCloseable { private record LocalChunkColumns(long epoch, ChunkColumns columns) { } + private record ChunkCoordinate(int x, int z) { + private long packed() { + return CacheKey.mix(RiverFootprint.pack(x, z)); + } + } + private record CacheLoadKey(long epoch, K key) { } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlan.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlan.java index 554e4e3e5..56e3675b2 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlan.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlan.java @@ -1,15 +1,16 @@ package art.arcane.iris.generation.hydrology.cave; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.HashCommon; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + import java.util.AbstractCollection; +import java.util.AbstractList; import java.util.AbstractMap; import java.util.AbstractSet; -import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; @@ -18,6 +19,8 @@ import java.util.OptionalLong; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.BiPredicate; +import java.util.function.IntConsumer; +import java.util.function.IntPredicate; public record HydrologyCavePlan( HydrologyCaveSource source, @@ -56,22 +59,10 @@ public record HydrologyCavePlan( actions = Map.of(); baselinePreconditions = Map.of(); } else { - LinkedHashMap packed = new LinkedHashMap<>(requestedPreconditions.size()); - for (Map.Entry entry : requestedActions.entrySet()) { - CaveVoxelPrecondition precondition = requestedPreconditions.get(entry.getKey()); - packed.put(entry.getKey(), encode(entry.getValue(), precondition)); - } - for (Map.Entry entry : requestedPreconditions.entrySet()) { - packed.putIfAbsent(entry.getKey(), encode(null, entry.getValue())); - } - Map immutablePacked = Collections.unmodifiableMap(packed); - PositionSpatialIndex spatialIndex = new PositionSpatialIndex(immutablePacked); - actions = new ActionMap(immutablePacked, requestedActions.size(), spatialIndex); - baselinePreconditions = new PreconditionMap( - immutablePacked, - List.copyOf(requestedPreconditions.keySet()), - spatialIndex - ); + PackedPositions packed = new PackedPositions(requestedActions, requestedPreconditions); + PositionSpatialIndex spatialIndex = new PositionSpatialIndex(packed); + actions = new ActionMap(packed, requestedActions.size(), spatialIndex); + baselinePreconditions = new PreconditionMap(packed, packed.preconditionOrder(requestedPreconditions), spatialIndex); } } @@ -79,6 +70,27 @@ public record HydrologyCavePlan( return rejection == HydrologyCaveRejection.NONE; } + public long estimatedRetainedBytes() { + if (!(baselinePreconditions instanceof PreconditionMap preconditions)) { + return 192L; + } + PackedPositions packed = preconditions.packed; + PositionSpatialIndex spatialIndex = preconditions.spatialIndex; + long spatialCapacity = (long) HashCommon.arraySize(spatialIndex.positionsByChunk.size(), 0.75F) + 1L; + long bytes = 512L + arrayBytes((long) packed.x.length * Integer.BYTES) * 3L + + arrayBytes(packed.flags.length) + arrayBytes((long) packed.slots.length * Integer.BYTES) + + arrayBytes((long) preconditions.order.rows.length * Integer.BYTES) + + arrayBytes(spatialCapacity * Long.BYTES) * 2L; + for (int[] rows : spatialIndex.positionsByChunk.values()) { + bytes += arrayBytes((long) rows.length * Integer.BYTES); + } + return bytes; + } + + private static long arrayBytes(long elementsBytes) { + return (elementsBytes + 23L) & ~7L; + } + public void forEachAction(BiConsumer consumer) { actions.forEach(consumer); } @@ -183,12 +195,129 @@ public record HydrologyCavePlan( return preconditions; } + private static final class PackedPositions { + private final int[] x; + private final int[] y; + private final int[] z; + private final byte[] flags; + private final int[] slots; + private final int mask; + private int count; + + private PackedPositions(Map actions, + Map preconditions) { + int size = preconditions.size(); + x = new int[size]; + y = new int[size]; + z = new int[size]; + flags = new byte[size]; + long required = Math.max(2L, ((long) size * 4L + 2L) / 3L); + if (required > 1 << 30) { + throw new IllegalArgumentException("Too many cave positions"); + } + int capacity = Integer.highestOneBit((int) required - 1) << 1; + slots = new int[capacity]; + mask = capacity - 1; + for (Map.Entry entry : actions.entrySet()) { + append(entry.getKey(), encode(entry.getValue(), preconditions.get(entry.getKey()))); + } + for (Map.Entry entry : preconditions.entrySet()) { + if (row(entry.getKey()) < 0) { + append(entry.getKey(), encode(null, entry.getValue())); + } + } + } + + private void append(CavePosition position, byte value) { + int slot = slot(position); + x[count] = position.x(); + y[count] = position.y(); + z[count] = position.z(); + flags[count] = value; + slots[slot] = ++count; + } + + private int slot(CavePosition position) { + int hash = 0x811C9DC5; + hash = (hash ^ position.x()) * 0x01000193; + hash = (hash ^ position.y()) * 0x01000193; + hash = (hash ^ position.z()) * 0x01000193; + hash ^= hash >>> 16; + hash *= 0x7FEB352D; + hash ^= hash >>> 15; + hash *= 0x846CA68B; + hash ^= hash >>> 16; + int slot = hash & mask; + while (slots[slot] != 0) { + int row = slots[slot] - 1; + if (x[row] == position.x() && y[row] == position.y() && z[row] == position.z()) { + return slot; + } + slot = slot + 1 & mask; + } + return slot; + } + + private int row(CavePosition position) { + return slots[slot(position)] - 1; + } + + private CavePosition position(int row) { + return new CavePosition(x[row], y[row], z[row]); + } + + private PositionOrder preconditionOrder(Map preconditions) { + int[] order = new int[count]; + int index = 0; + for (CavePosition position : preconditions.keySet()) { + order[index++] = row(position); + } + return new PositionOrder(this, order); + } + + public Byte get(Object key) { + if (!(key instanceof CavePosition position)) { + return null; + } + int row = row(position); + return row < 0 ? null : flags[row]; + } + + public boolean containsKey(Object key) { + return key instanceof CavePosition position && row(position) >= 0; + } + + public int size() { + return count; + } + } + + private static final class PositionOrder extends AbstractList { + private final PackedPositions packed; + private final int[] rows; + + private PositionOrder(PackedPositions packed, int[] rows) { + this.packed = packed; + this.rows = rows; + } + + @Override + public CavePosition get(int index) { + return packed.position(rows[index]); + } + + @Override + public int size() { + return rows.length; + } + } + private static final class ActionMap extends AbstractMap { - private final Map packed; + private final PackedPositions packed; private final int size; private final PositionSpatialIndex spatialIndex; - private ActionMap(Map packed, int size, PositionSpatialIndex spatialIndex) { + private ActionMap(PackedPositions packed, int size, PositionSpatialIndex spatialIndex) { this.packed = packed; this.size = size; this.spatialIndex = spatialIndex; @@ -229,16 +358,16 @@ public record HydrologyCavePlan( @Override public void forEach(BiConsumer consumer) { Objects.requireNonNull(consumer); - for (Map.Entry entry : packed.entrySet()) { - if (hasAction(entry.getValue())) { - consumer.accept(entry.getKey(), decodeAction(entry.getValue())); + for (int row = 0; row < packed.size(); row++) { + if (hasAction(packed.flags[row])) { + consumer.accept(packed.position(row), decodeAction(packed.flags[row])); } } } private boolean intersects(int minimumX, int minimumZ, int maximumX, int maximumZ) { return spatialIndex.anyIn(minimumX, minimumZ, maximumX, maximumZ, - (CavePosition position) -> hasAction(packed.get(position))); + (int row) -> hasAction(packed.flags[row])); } private void forEachIn( @@ -248,23 +377,23 @@ public record HydrologyCavePlan( int maximumZ, BiConsumer consumer ) { - spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (CavePosition position) -> { - byte value = packed.get(position); + spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (int row) -> { + byte value = packed.flags[row]; if (hasAction(value)) { - consumer.accept(position, decodeAction(value)); + consumer.accept(packed.position(row), decodeAction(value)); } }); } } private static final class PreconditionMap extends AbstractMap { - private final Map packed; - private final List order; + private final PackedPositions packed; + private final PositionOrder order; private final PositionSpatialIndex spatialIndex; private PreconditionMap( - Map packed, - List order, + PackedPositions packed, + PositionOrder order, PositionSpatialIndex spatialIndex ) { this.packed = packed; @@ -306,8 +435,8 @@ public record HydrologyCavePlan( @Override public void forEach(BiConsumer consumer) { Objects.requireNonNull(consumer); - for (CavePosition position : order) { - consumer.accept(position, decodePrecondition(packed.get(position))); + for (int row : order.rows) { + consumer.accept(packed.position(row), decodePrecondition(packed.flags[row])); } } @@ -319,26 +448,32 @@ public record HydrologyCavePlan( BiPredicate predicate ) { return spatialIndex.allIn(minimumX, minimumZ, maximumX, maximumZ, - (CavePosition position) -> predicate.test(position, decodePrecondition(packed.get(position)))); + (int row) -> predicate.test(packed.position(row), decodePrecondition(packed.flags[row]))); } } private static final class PositionSpatialIndex { private static final int CHUNK_SHIFT = 4; - private final Map> positionsByChunk; + private final PackedPositions packed; + private final Long2ObjectOpenHashMap positionsByChunk; - private PositionSpatialIndex(Map packed) { - HashMap> mutable = new HashMap<>(); - for (CavePosition position : packed.keySet()) { - long chunkKey = packChunk(position.x() >> CHUNK_SHIFT, position.z() >> CHUNK_SHIFT); - mutable.computeIfAbsent(chunkKey, ignored -> new ArrayList<>()).add(position); + private PositionSpatialIndex(PackedPositions packed) { + this.packed = packed; + Long2ObjectOpenHashMap mutable = new Long2ObjectOpenHashMap<>(); + for (int row = 0; row < packed.size(); row++) { + long chunkKey = packChunk(packed.x[row] >> CHUNK_SHIFT, packed.z[row] >> CHUNK_SHIFT); + IntArrayList rows = mutable.get(chunkKey); + if (rows == null) { + rows = new IntArrayList(); + mutable.put(chunkKey, rows); + } + rows.add(row); } - HashMap> immutable = HashMap.newHashMap(mutable.size()); - for (Map.Entry> entry : mutable.entrySet()) { - immutable.put(entry.getKey(), List.copyOf(entry.getValue())); + positionsByChunk = new Long2ObjectOpenHashMap<>(mutable.size()); + for (Long2ObjectMap.Entry entry : mutable.long2ObjectEntrySet()) { + positionsByChunk.put(entry.getLongKey(), entry.getValue().toIntArray()); } - positionsByChunk = Map.copyOf(immutable); } private boolean anyIn( @@ -346,7 +481,7 @@ public record HydrologyCavePlan( int minimumZ, int maximumX, int maximumZ, - java.util.function.Predicate predicate + IntPredicate predicate ) { int minimumChunkX = minimumX >> CHUNK_SHIFT; int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT; @@ -354,13 +489,13 @@ public record HydrologyCavePlan( int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT; for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { - List positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); + int[] positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); if (positions == null) { continue; } - for (CavePosition position : positions) { - if (inside(position, minimumX, minimumZ, maximumX, maximumZ) - && predicate.test(position)) { + for (int row : positions) { + if (inside(row, minimumX, minimumZ, maximumX, maximumZ) + && predicate.test(row)) { return true; } } @@ -374,7 +509,7 @@ public record HydrologyCavePlan( int minimumZ, int maximumX, int maximumZ, - java.util.function.Predicate predicate + IntPredicate predicate ) { int minimumChunkX = minimumX >> CHUNK_SHIFT; int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT; @@ -382,13 +517,13 @@ public record HydrologyCavePlan( int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT; for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { - List positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); + int[] positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); if (positions == null) { continue; } - for (CavePosition position : positions) { - if (inside(position, minimumX, minimumZ, maximumX, maximumZ) - && !predicate.test(position)) { + for (int row : positions) { + if (inside(row, minimumX, minimumZ, maximumX, maximumZ) + && !predicate.test(row)) { return false; } } @@ -402,7 +537,7 @@ public record HydrologyCavePlan( int minimumZ, int maximumX, int maximumZ, - java.util.function.Consumer consumer + IntConsumer consumer ) { int minimumChunkX = minimumX >> CHUNK_SHIFT; int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT; @@ -410,28 +545,28 @@ public record HydrologyCavePlan( int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT; for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { - List positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); + int[] positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); if (positions == null) { continue; } - for (CavePosition position : positions) { - if (inside(position, minimumX, minimumZ, maximumX, maximumZ)) { - consumer.accept(position); + for (int row : positions) { + if (inside(row, minimumX, minimumZ, maximumX, maximumZ)) { + consumer.accept(row); } } } } } - private static boolean inside( - CavePosition position, + private boolean inside( + int row, int minimumX, int minimumZ, int maximumX, int maximumZ ) { - return position.x() >= minimumX && position.x() < maximumX - && position.z() >= minimumZ && position.z() < maximumZ; + return packed.x[row] >= minimumX && packed.x[row] < maximumX + && packed.z[row] >= minimumZ && packed.z[row] < maximumZ; } private static long packChunk(int chunkX, int chunkZ) { @@ -440,17 +575,17 @@ public record HydrologyCavePlan( } private static final class ActionKeySet extends AbstractSet { - private final Map packed; + private final PackedPositions packed; private final int size; - private ActionKeySet(Map packed, int size) { + private ActionKeySet(PackedPositions packed, int size) { this.packed = packed; this.size = size; } @Override public Iterator iterator() { - return new ActionKeyIterator(packed.entrySet().iterator()); + return new ActionKeyIterator(packed); } @Override @@ -466,17 +601,17 @@ public record HydrologyCavePlan( } private static final class ActionEntrySet extends AbstractSet> { - private final Map packed; + private final PackedPositions packed; private final int size; - private ActionEntrySet(Map packed, int size) { + private ActionEntrySet(PackedPositions packed, int size) { this.packed = packed; this.size = size; } @Override public Iterator> iterator() { - return new ActionEntryIterator(packed.entrySet().iterator()); + return new ActionEntryIterator(packed); } @Override @@ -487,17 +622,17 @@ public record HydrologyCavePlan( private static final class PreconditionEntrySet extends AbstractSet> { - private final Map packed; - private final List order; + private final PackedPositions packed; + private final PositionOrder order; - private PreconditionEntrySet(Map packed, List order) { + private PreconditionEntrySet(PackedPositions packed, PositionOrder order) { this.packed = packed; this.order = order; } @Override public Iterator> iterator() { - return new PreconditionEntryIterator(packed, order.iterator()); + return new PreconditionEntryIterator(packed, order.rows); } @Override @@ -507,10 +642,10 @@ public record HydrologyCavePlan( } private static final class PreconditionKeySet extends AbstractSet { - private final Map packed; - private final List order; + private final PackedPositions packed; + private final PositionOrder order; - private PreconditionKeySet(Map packed, List order) { + private PreconditionKeySet(PackedPositions packed, PositionOrder order) { this.packed = packed; this.order = order; } @@ -532,17 +667,17 @@ public record HydrologyCavePlan( } private static final class ActionValues extends AbstractCollection { - private final Map packed; + private final PackedPositions packed; private final int size; - private ActionValues(Map packed, int size) { + private ActionValues(PackedPositions packed, int size) { this.packed = packed; this.size = size; } @Override public Iterator iterator() { - return new ActionValueIterator(packed.entrySet().iterator()); + return new ActionValueIterator(packed); } @Override @@ -552,17 +687,17 @@ public record HydrologyCavePlan( } private static final class PreconditionValues extends AbstractCollection { - private final Map packed; - private final List order; + private final PackedPositions packed; + private final PositionOrder order; - private PreconditionValues(Map packed, List order) { + private PreconditionValues(PackedPositions packed, PositionOrder order) { this.packed = packed; this.order = order; } @Override public Iterator iterator() { - return new PreconditionValueIterator(packed, order.iterator()); + return new PreconditionValueIterator(packed, order.rows); } @Override @@ -572,121 +707,123 @@ public record HydrologyCavePlan( } private abstract static class FilteredActionIterator implements Iterator { - private final Iterator> entries; - private Map.Entry next; + protected final PackedPositions packed; + private int next; - private FilteredActionIterator(Iterator> entries) { - this.entries = entries; + private FilteredActionIterator(PackedPositions packed) { + this.packed = packed; } @Override public boolean hasNext() { advance(); - return next != null; + return next < packed.size(); } @Override public T next() { - advance(); - if (next == null) { + if (!hasNext()) { throw new NoSuchElementException(); } - Map.Entry selected = next; - next = null; - return map(selected); + return map(next++); } - protected abstract T map(Map.Entry entry); + protected abstract T map(int row); private void advance() { - while (next == null && entries.hasNext()) { - Map.Entry candidate = entries.next(); - if (hasAction(candidate.getValue())) { - next = candidate; - } + while (next < packed.size() && !hasAction(packed.flags[next])) { + next++; } } } private static final class ActionKeyIterator extends FilteredActionIterator { - private ActionKeyIterator(Iterator> entries) { - super(entries); + private ActionKeyIterator(PackedPositions packed) { + super(packed); } @Override - protected CavePosition map(Map.Entry entry) { - return entry.getKey(); + protected CavePosition map(int row) { + return packed.position(row); } } private static final class ActionEntryIterator extends FilteredActionIterator> { - private ActionEntryIterator(Iterator> entries) { - super(entries); + private ActionEntryIterator(PackedPositions packed) { + super(packed); } @Override - protected Entry map(Map.Entry entry) { - return Map.entry(entry.getKey(), decodeAction(entry.getValue())); + protected Entry map(int row) { + return Map.entry(packed.position(row), decodeAction(packed.flags[row])); } } private static final class ActionValueIterator extends FilteredActionIterator { - private ActionValueIterator(Iterator> entries) { - super(entries); + private ActionValueIterator(PackedPositions packed) { + super(packed); } @Override - protected HydrologyCaveAction map(Map.Entry entry) { - return decodeAction(entry.getValue()); + protected HydrologyCaveAction map(int row) { + return decodeAction(packed.flags[row]); } } private static final class PreconditionEntryIterator implements Iterator> { - private final Map packed; - private final Iterator positions; + private final PackedPositions packed; + private final int[] rows; + private int index; private PreconditionEntryIterator( - Map packed, - Iterator positions + PackedPositions packed, + int[] rows ) { this.packed = packed; - this.positions = positions; + this.rows = rows; } @Override public boolean hasNext() { - return positions.hasNext(); + return index < rows.length; } @Override public Entry next() { - CavePosition position = positions.next(); - return Map.entry(position, decodePrecondition(packed.get(position))); + if (!hasNext()) { + throw new NoSuchElementException(); + } + int row = rows[index++]; + return Map.entry(packed.position(row), decodePrecondition(packed.flags[row])); } } private static final class PreconditionValueIterator implements Iterator { - private final Map packed; - private final Iterator positions; + private final PackedPositions packed; + private final int[] rows; + private int index; private PreconditionValueIterator( - Map packed, - Iterator positions + PackedPositions packed, + int[] rows ) { this.packed = packed; - this.positions = positions; + this.rows = rows; } @Override public boolean hasNext() { - return positions.hasNext(); + return index < rows.length; } @Override public CaveVoxelPrecondition next() { - return decodePrecondition(packed.get(positions.next())); + if (!hasNext()) { + throw new NoSuchElementException(); + } + return decodePrecondition(packed.flags[rows[index++]]); } } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index 2d3bcab17..bafa65b52 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -45,6 +45,11 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai } } + @Override + public boolean supportsSharedGridSamples() { + return true; + } + @Override public HydrologyTerrainSample[] sampleGrid(GridRequest request) { int minimumX = request.minimumX(); @@ -125,6 +130,33 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return basis(blockX, blockZ).terrain(); } + @Override + public double sampleLandHeight(int blockX, int blockZ) { + long packed = pack(blockX, blockZ); + CacheStripe stripe = stripe(packed); + synchronized (stripe) { + TerrainBasis cached = stripe.bases.getAndMoveToLast(packed); + if (cached != null) { + return cached.terrain().ocean() ? Double.NaN : cached.terrain().naturalHeight(); + } + } + double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ); + if (!Double.isFinite(naturalHeight)) { + TerrainBasis basis = loadBasis(blockX, blockZ, stripe, packed, naturalHeight); + return basis.terrain().ocean() ? Double.NaN : basis.terrain().naturalHeight(); + } + long roundedHeight = StrictMath.round(naturalHeight); + if (roundedHeight >= seaLevel) { + return (int) roundedHeight; + } + NaturalClassification classification = classifyNatural(blockX, blockZ); + if (classification == NaturalClassification.UNAVAILABLE) { + TerrainBasis basis = loadBasis(blockX, blockZ, stripe, packed, naturalHeight); + return basis.terrain().ocean() ? Double.NaN : basis.terrain().naturalHeight(); + } + return classification == NaturalClassification.OCEAN ? Double.NaN : (int) roundedHeight; + } + TerrainBasis basis(int blockX, int blockZ) { long packed = pack(blockX, blockZ); CacheStripe stripe = stripe(packed); @@ -134,7 +166,10 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return cached; } } - double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ); + return loadBasis(blockX, blockZ, stripe, packed, naturalHeight(stripe, packed, blockX, blockZ)); + } + + private TerrainBasis loadBasis(int blockX, int blockZ, CacheStripe stripe, long packed, double naturalHeight) { TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight); if (sampled == null) { throw new NullPointerException( diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java index 867198c7d..fa21b3a05 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java @@ -79,6 +79,32 @@ public final class ErosionFieldCompiler { HydrologyPlannerSettings.Ponds ponds, SurfaceRasterContext context ) { + return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, context, + prepare(centerline, channel, valley, terminal)); + } + + PreparedField prepare(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, + SurfaceTerminal terminal) { + int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations(); + boolean[] basin = basins(valley, channel, count); + double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel); + double[][] blendWidth = blendWidths(centerline, channel, valley, count); + int[] radii = new int[count]; + for (int station = 0; station < count; station++) { + double widest = Math.max(blendWidth[station][0], blendWidth[station][1]); + // The shore biome band a policy asks for can be wider than the geometric shore and the + // eroded valley; the search radius has to reach it so those columns get a shore role. + double stationBand = stationShoreBiomeBand(centerline, station, surface.shoreWidth()); + double stationShore = stationShoreWidth(centerline, station, surface.shoreWidth()); + radii[station] = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + surface.banks().roughness()) + + Math.max(stationShore + widest, stationBand)) + 1; + } + return new PreparedField(count, basin, bendOffsets, blendWidth, radii); + } + + ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, + SurfaceTerminal terminal, int apronLimit, HydrologyPlannerSettings.Ponds ponds, + SurfaceRasterContext context, PreparedField prepared) { Objects.requireNonNull(context, "context"); SurfaceBounds bounds = context.bounds(); SurfaceBounds samplingBounds = bounds == null ? null : bounds.expand(2); @@ -87,24 +113,19 @@ public final class ErosionFieldCompiler { HydrologyPlannerSettings.Erosion erosion = banks.erosion(); HydrologyPlannerSettings.Channel channelSettings = banks.channel(); HydrologyPlannerSettings.Flow flow = banks.flow(); - int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations(); + int count = prepared.count(); double roughness = banks.roughness(); double shoreWidth = surface.shoreWidth(); double shoreRise = erosion.shoreRise(); int sink = banks.sink(); - boolean[] basin = basins(valley, channel, count); - double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel); - double[][] blendWidth = blendWidths(centerline, channel, valley, count); + boolean[] basin = prepared.basin(); + double[] bendOffsets = prepared.bendOffsets(); + double[][] blendWidth = prepared.blendWidth(); Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); nearest.defaultReturnValue(-1); Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); for (int station = 0; station < count; station++) { - double widest = Math.max(blendWidth[station][0], blendWidth[station][1]); - // The shore biome band a policy asks for can be wider than the geometric shore and the - // eroded valley; the search radius has to reach it so those columns get a shore role. - double stationBand = stationShoreBiomeBand(centerline, station, shoreWidth); - double stationShore = stationShoreWidth(centerline, station, shoreWidth); - int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + Math.max(stationShore + widest, stationBand)) + 1; + int radius = prepared.radii()[station]; int stationX = centerline.x()[station]; int stationZ = centerline.z()[station]; if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) { @@ -743,4 +764,7 @@ public final class ErosionFieldCompiler { double deltaZ = cellZ - centerline.z()[station]; return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station); } + + record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java index 1394dff0a..9d54a73c6 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java @@ -51,14 +51,30 @@ public final class SurfaceFootprintCompiler { } public SurfaceFootprint compile(RiverCourse course, SurfaceBounds bounds) { + return compile(prepare(course), bounds); + } + + public PreparedCourse prepare(RiverCourse course) { + return new PreparedCourse(this, Objects.requireNonNull(course, "course")); + } + + public SurfaceFootprint compile(PreparedCourse prepared, SurfaceBounds bounds) { + if (prepared.compiler != this) { + throw new IllegalArgumentException("Prepared course belongs to another surface compiler"); + } + RiverCourse course = prepared.course; boolean pool = course.type() == RiverCourseType.SURFACE_POOL; if (course.type() != RiverCourseType.SURFACE && !pool) { return SurfaceFootprint.empty(); } - HydrologyTerrainSampler receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course); + if (prepared.receivingSampler == null) { + prepared.receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course); + prepared.offsets = stationOffsets(course.segments()); + } + HydrologyTerrainSampler receivingSampler = prepared.receivingSampler; HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course, bounds == null ? null : bounds.expand(2)); - int[] offsets = stationOffsets(course.segments()); + int[] offsets = prepared.offsets; ArrayList columns = new ArrayList<>(); int uncontained = 0; long excavation = 0L; @@ -74,7 +90,7 @@ public final class SurfaceFootprintCompiler { while (after < course.segments().size() && exposedSegment(course.segments().get(after))) { after++; } - SurfaceFootprint run = compileRun(course, first, after, offsets[first], bounds, drops, pool, receivingSampler); + SurfaceFootprint run = compileRun(prepared, first, after, offsets[first], bounds, drops, pool, receivingSampler); columns.addAll(run.columns()); uncontained += run.uncontainedWetCells(); excavation += run.bankExcavation(); @@ -106,14 +122,10 @@ public final class SurfaceFootprintCompiler { return new SurfaceFootprint(columns, uncontained, rejection, detail, excavation); } - private SurfaceFootprint compileRun(RiverCourse course, int first, int after, int stationOffset, - SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool, - HydrologyTerrainSampler receivingSampler) { + private PreparedRun prepareRun(RiverCourse course, int first, int after, boolean pool, + HydrologyTerrainSampler receivingSampler) { List exposed = course.segments().subList(first, after); Stations stations = stations(exposed); - if (stations.count() < 1) { - return SurfaceFootprint.empty(); - } boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid(); SurfaceTerminal terminal = pool ? SurfaceTerminal.SINKHOLE : fallingEnd ? SurfaceTerminal.TRIBUTARY : terminal(course, exposed); @@ -129,9 +141,29 @@ public final class SurfaceFootprintCompiler { : new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(), fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal()); SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel); - ErosionField field = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel()).compile( - HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, channel, valley, terminal, - settings.outlets().maximumOceanApron(), ponds, new SurfaceRasterContext(bounds, drops, boundary)); + ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel()); + return new PreparedRun(exposed, stations, coastalChannel, centerline, channel, poolBiome, valley, + terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal)); + } + + private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset, + SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool, + HydrologyTerrainSampler receivingSampler) { + RiverCourse course = prepared.course; + PreparedRun run = prepared.runs[first]; + if (run == null) { + run = prepareRun(course, first, after, pool, receivingSampler); + prepared.runs[first] = run; + } + List exposed = run.exposed(); + Stations stations = run.stations(); + SurfaceCenterline centerline = run.centerline(); + boolean coastalChannel = run.coastalChannel(); + String poolBiome = run.poolBiome(); + ErosionField field = run.erosion().compile( + HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, run.channel(), run.valley(), run.terminal(), + settings.outlets().maximumOceanApron(), run.ponds(), new SurfaceRasterContext(bounds, drops, run.boundary()), + run.field()); ArrayList ordered = new ArrayList<>(field.columns().values()); ordered.sort(Comparator.comparingInt(SurfaceColumn::station) .thenComparingLong((SurfaceColumn column) -> RiverFootprint.pack(column.x(), column.z()))); @@ -367,8 +399,25 @@ public final class SurfaceFootprintCompiler { private record Stations(List points, int[] segmentIndex, int[] head, double[] width, double[] depth, int exposedStations) { - private int count() { - return points.size(); + } + + public static final class PreparedCourse { + private final SurfaceFootprintCompiler compiler; + private final RiverCourse course; + private final PreparedRun[] runs; + private HydrologyTerrainSampler receivingSampler; + private int[] offsets; + + private PreparedCourse(SurfaceFootprintCompiler compiler, RiverCourse course) { + this.compiler = compiler; + this.course = course; + this.runs = new PreparedRun[course.segments().size()]; } } + + private record PreparedRun(List exposed, Stations stations, boolean coastalChannel, + SurfaceCenterline centerline, ChannelProfile channel, String poolBiome, ValleyProfile valley, + SurfaceTerminal terminal, HydrologyPlannerSettings.Ponds ponds, SurfaceRunBoundary boundary, + ErosionFieldCompiler erosion, ErosionFieldCompiler.PreparedField field) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java index 42b0a204c..b6241b4c0 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java @@ -356,7 +356,11 @@ public interface EngineMantle extends MatterGenerator { } default boolean cleanupCoveredChunk(int x, int z, boolean force) { - MantleChunk chunk = getMantle().getChunk(x, z).use(); + return cleanupCoveredChunk(getMantle().getChunk(x, z), force); + } + + default boolean cleanupCoveredChunk(MantleChunk chunk, boolean force) { + chunk.use(); try { synchronized (chunk) { if (chunk.isFlagged(MantleFlag.CLEANED)) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index ed39c825c..eafcc077d 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -599,7 +599,7 @@ public class IrisComplex implements DataProvider { } private boolean sampleNaturalOcean(int x, int z) { - return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA; + return resolveNaturalInferredType(bridgeStream, focusedBiomeAt(x, z), x, z) == InferredType.SEA; } /** diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngineMantle.java index b2d7f6161..82e8c324c 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngineMantle.java @@ -138,11 +138,13 @@ public class IrisEngineMantle implements EngineMantle { int candidateX = newRealX + offsetX; for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { int candidateZ = newRealZ + offsetZ; - if (getMantle().hasFlag(candidateX, candidateZ, MantleFlag.CLEANED) + if (!isCoverageResident(candidateX, candidateZ, radius) + || getMantle().hasLoadedFlag(candidateX, candidateZ, MantleFlag.CLEANED) || !isCovered(candidateX, candidateZ, missingChunk)) { continue; } - if (cleanupCoveredChunk(candidateX, candidateZ, force)) { + if (getMantle().withLoadedChunk(candidateX, candidateZ, + chunk -> cleanupCoveredChunk(chunk, force))) { callback.onChunkCleaned(candidateX, candidateZ); } } @@ -246,12 +248,23 @@ public class IrisEngineMantle implements EngineMantle { return Objects.requireNonNull(storageDirectory, "mantle storage directory").toAbsolutePath().normalize(); } + private boolean isCoverageResident(int x, int z, int radius) { + for (int regionX = (x - radius) >> 5; regionX <= (x + radius) >> 5; regionX++) { + for (int regionZ = (z - radius) >> 5; regionZ <= (z + radius) >> 5; regionZ++) { + if (!getMantle().isChunkLoaded(regionX << 5, regionZ << 5)) { + return false; + } + } + } + return true; + } + private boolean isCovered(int x, int z, MissingRealChunk missingChunk) { int radius = Math.max(getRadius(), getRealRadius()); if (missingChunk != null && missingChunk.known && Math.abs((long) missingChunk.x - x) <= radius && Math.abs((long) missingChunk.z - z) <= radius) { - if (!getMantle().hasFlag(missingChunk.x, missingChunk.z, MantleFlag.REAL)) { + if (!getMantle().hasLoadedFlag(missingChunk.x, missingChunk.z, MantleFlag.REAL)) { return false; } missingChunk.known = false; @@ -260,7 +273,7 @@ public class IrisEngineMantle implements EngineMantle { for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { int chunkX = x + offsetX; int chunkZ = z + offsetZ; - if (!getMantle().hasFlag(chunkX, chunkZ, MantleFlag.REAL)) { + if (!getMantle().hasLoadedFlag(chunkX, chunkZ, MantleFlag.REAL)) { if (missingChunk != null) { missingChunk.x = chunkX; missingChunk.z = chunkZ; @@ -352,14 +365,16 @@ public class IrisEngineMantle implements EngineMantle { @Override public void remove(Matter section, int x, int y, int z, Class type) { - MatterSlice slice = section.slice(type); - slice.set(x, y, z, null); + MatterSlice slice = section.getSlice(type); + if (slice != null) { + slice.set(x, y, z, null); + } } @Override public T get(Matter section, int x, int y, int z, Class type) { - MatterSlice slice = section.slice(type); - return slice.get(x, y, z); + MatterSlice slice = section.getSlice(type); + return slice == null ? null : slice.get(x, y, z); } @Override diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java index 48b5736d9..783b0d553 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java @@ -223,6 +223,10 @@ public final class BukkitBlockResolution { return BASE.getNoCompat(bdxf); } + public static BlockData decodeBlockData(String key) { + return BASE.decodeBlockData(key); + } + public static BlockData get(String bdxf) { return BASE.get(bdxf); } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java index d8db630f0..d320ea760 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java @@ -60,6 +60,11 @@ public final class BukkitRegistries implements PlatformRegistries { return INMS.get().generationRegistry(); } + @Override + public NativeBlockState decodeBlockState(String key) { + return BukkitBlockState.of(BukkitBlockResolution.decodeBlockData(key)); + } + @Override public NativeBlockState block(String key) { BlockData data = BukkitBlockResolution.get(key); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java index 121866192..ed8b31e38 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java @@ -345,7 +345,7 @@ public class IrisPregenerator { int previousLoaded = loadedBefore; int stagnantRounds = 0; for (int attempt = 0; attempt < 24 && stagnantRounds < 4; attempt++) { - mantle.trim(0, 0); + mantle.trim(0L); mantle.unloadTectonicPlate(0); int loaded = mantle.getLoadedRegionCount(); if (loaded <= 0) { @@ -441,8 +441,7 @@ public class IrisPregenerator { try { Mantle mantle = getMantle(); if (mantle != null) { - mantle.trim(0, 0); - mantle.unloadTectonicPlate(0); + mantle.saveOldestIdleTectonicPlate(); } } catch (Throwable e) { IrisLogging.reportError(e); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java index 59bb861c8..5dce2e6cb 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java @@ -34,7 +34,7 @@ public final class MantleHeapPressure { private static final long MAXIMUM_HYSTERESIS_MS = 60_000L; private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName(); private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer( - new PanicGcPolicy(10_000L, 60_000L, 60_000L, 15 * 60_000L), + new PanicGcPolicy(250L, 5_000L, 10_000L, 60_000L, 60_000L, 15 * 60_000L), new PanicGcActions( System::currentTimeMillis, System::gc, @@ -85,7 +85,8 @@ public final class MantleHeapPressure { } public static void requestPanicReclaim() { - PANIC_RECLAIMER.request(usedFraction()); + double fraction = usedFraction(); + PANIC_RECLAIMER.request(fraction, PRESSURE_GATE.update(fraction)); } static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception { @@ -108,13 +109,17 @@ public final class MantleHeapPressure { } record PanicGcPolicy( + long explicitGraceMs, + long explicitCooldownMs, long diagnosticDelayMs, long diagnosticCooldownMs, long initialFailureBackoffMs, long maximumFailureBackoffMs ) { PanicGcPolicy { - if (diagnosticDelayMs < 0L + if (explicitGraceMs < 0L + || explicitCooldownMs < 0L + || diagnosticDelayMs < 0L || diagnosticCooldownMs < 0L || initialFailureBackoffMs < 0L || maximumFailureBackoffMs < initialFailureBackoffMs) { @@ -138,15 +143,13 @@ public final class MantleHeapPressure { } static final class HeapPressureGate { - private static final long NOT_BELOW_HIGH_WATER = Long.MIN_VALUE; - private final double highWater; private final double lowWater; private final long maximumHysteresisMs; private final LongSupplier clock; private final Runnable releaseAction; private boolean engaged; - private long belowHighWaterSince; + private long episodeStartedAt; HeapPressureGate(double highWater, double lowWater, long maximumHysteresisMs, LongSupplier clock, Runnable releaseAction) { if (!Double.isFinite(highWater) @@ -161,7 +164,6 @@ public final class MantleHeapPressure { this.maximumHysteresisMs = maximumHysteresisMs; this.clock = clock; this.releaseAction = releaseAction; - this.belowHighWaterSince = NOT_BELOW_HIGH_WATER; } synchronized boolean update(double fraction) { @@ -171,7 +173,7 @@ public final class MantleHeapPressure { if (!engaged) { if (fraction >= highWater) { engaged = true; - belowHighWaterSince = NOT_BELOW_HIGH_WATER; + episodeStartedAt = clock.getAsLong(); } return engaged; } @@ -180,16 +182,11 @@ public final class MantleHeapPressure { return false; } if (fraction >= highWater) { - belowHighWaterSince = NOT_BELOW_HIGH_WATER; return true; } long now = clock.getAsLong(); - if (belowHighWaterSince == NOT_BELOW_HIGH_WATER) { - belowHighWaterSince = now; - return true; - } - if (elapsed(now, belowHighWaterSince) < maximumHysteresisMs) { + if (elapsed(now, episodeStartedAt) < maximumHysteresisMs) { return true; } @@ -199,7 +196,7 @@ public final class MantleHeapPressure { private void release() { engaged = false; - belowHighWaterSince = NOT_BELOW_HIGH_WATER; + episodeStartedAt = 0L; releaseAction.run(); } @@ -212,7 +209,10 @@ public final class MantleHeapPressure { private final PanicGcPolicy policy; private final PanicGcActions actions; private boolean panicEpisode; + private boolean explicitAttempted; + private long episodeStartedAt; private long explicitAttemptAt; + private long nextExplicitAllowedAt = Long.MIN_VALUE; private long nextDiagnosticAllowedAt; private long failureBackoffMs; @@ -222,17 +222,29 @@ public final class MantleHeapPressure { this.failureBackoffMs = policy.initialFailureBackoffMs(); } - synchronized void request(double fraction) { - if (!Double.isFinite(fraction) || fraction <= LOW_WATER) { + synchronized void request(double fraction, boolean pressureEngaged) { + if (!Double.isFinite(fraction)) { + return; + } + if (fraction <= LOW_WATER) { + resetEpisode(); return; } long now = actions.clock().getAsLong(); if (!panicEpisode) { - if (fraction < HIGH_WATER) { + if (fraction < HIGH_WATER && !pressureEngaged) { return; } - beginEpisode(now); + panicEpisode = true; + episodeStartedAt = now; + } + if (!explicitAttempted) { + if (elapsed(now, episodeStartedAt) < policy.explicitGraceMs() + || now < nextExplicitAllowedAt) { + return; + } + attemptExplicitGc(now); return; } if (elapsed(now, explicitAttemptAt) < policy.diagnosticDelayMs() @@ -256,12 +268,15 @@ public final class MantleHeapPressure { synchronized void resetEpisode() { panicEpisode = false; + explicitAttempted = false; + episodeStartedAt = 0L; explicitAttemptAt = 0L; } - private void beginEpisode(long now) { - panicEpisode = true; + private void attemptExplicitGc(long now) { + explicitAttempted = true; explicitAttemptAt = now; + nextExplicitAllowedAt = deadline(now, policy.explicitCooldownMs()); try { actions.explicitGc().run(); } catch (RuntimeException failure) { diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java index 1ac851015..b87eebbf6 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java @@ -84,7 +84,7 @@ public final class PregenMantleBackpressure { int freed; int resident; try { - mantle.trim(0L, 0); + mantle.trim(0L); freed = mantle.unloadTectonicPlate(0); resident = mantle.getLoadedRegionCount(); } catch (Throwable e) { @@ -120,24 +120,27 @@ public final class PregenMantleBackpressure { } public void awaitHeapHeadroom() { + awaitHeapHeadroom(MantleHeapPressure::overHighWater, MantleHeapPressure::requestPanicReclaim); + } + + void awaitHeapHeadroom(BooleanSupplier heapPressure, Runnable panicReclaim) { Mantle mantle = resolveMantle(); long waitStart = M.ms(); long lastLog = 0L; - while (MantleHeapPressure.overHighWater()) { + while (heapPressure.getAsBoolean()) { if (isCancelled()) { return; } try { - if (mantle != null && mantle.getLoadedRegionCount() > maxResidentTectonicPlates) { - mantle.trim(0L, 0); - mantle.unloadTectonicPlate(0); + if (mantle != null) { + mantle.saveOldestIdleTectonicPlate(); } } catch (Throwable e) { IrisLogging.reportError(e); } - MantleHeapPressure.requestPanicReclaim(); + panicReclaim.run(); long elapsed = M.ms() - waitStart; if (elapsed >= timeoutMs) { diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/PlatformBlockMatter.java b/core/src/main/java/art/arcane/iris/world/storage/matter/PlatformBlockMatter.java index 6f9326df4..484c0bee9 100644 --- a/core/src/main/java/art/arcane/iris/world/storage/matter/PlatformBlockMatter.java +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/PlatformBlockMatter.java @@ -2,6 +2,7 @@ package art.arcane.iris.world.storage.matter; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.block.B; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.data.palette.Palette; import art.arcane.volmlib.util.matter.Sliced; import art.arcane.volmlib.util.matter.slices.RawMatter; @@ -33,7 +34,7 @@ public class PlatformBlockMatter extends RawMatter { @Override public NativeBlockState readNode(DataInputStream din) throws IOException { - NativeBlockState state = B.getState(din.readUTF()); + NativeBlockState state = IrisPlatforms.get().registries().decodeBlockState(din.readUTF()); return state == null ? B.getAirState() : state; } } diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/PreObjectMatter.java b/core/src/main/java/art/arcane/iris/world/storage/matter/PreObjectMatter.java index 9283ee214..09fcbe406 100644 --- a/core/src/main/java/art/arcane/iris/world/storage/matter/PreObjectMatter.java +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/PreObjectMatter.java @@ -2,7 +2,7 @@ package art.arcane.iris.world.storage.matter; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; -import art.arcane.iris.generation.block.B; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.data.palette.Palette; import art.arcane.volmlib.util.function.Consumer4; import art.arcane.volmlib.util.function.Consumer4IO; @@ -114,7 +114,7 @@ public final class PreObjectMatter extends MappedHunk public PreObjectMatterCell readNode(DataInputStream input) throws IOException { int flags = input.readUnsignedByte(); validateFlags(flags); - NativeBlockState block = (flags & BLOCK_PRESENT) == 0 ? null : B.getState(input.readUTF()); + NativeBlockState block = (flags & BLOCK_PRESENT) == 0 ? null : IrisPlatforms.get().registries().decodeBlockState(input.readUTF()); String string = (flags & STRING_PRESENT) == 0 ? null : input.readUTF(); MatterCavern cavern = (flags & CAVERN_PRESENT) == 0 ? null diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java new file mode 100644 index 000000000..3c1d4df21 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java @@ -0,0 +1,118 @@ +package art.arcane.iris.generation.hydrology; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyCacheBudgetTest { + private static final long MEBIBYTE = 1024L * 1024L; + + @Test + public void retainedCacheAllowancesShareOneHeapScaledLimit() { + for (long heap : new long[]{32L * MEBIBYTE, 1024L * MEBIBYTE, 2048L * MEBIBYTE, 4096L * MEBIBYTE, Long.MAX_VALUE}) { + for (boolean regionalEnabled : new boolean[]{false, true}) { + HydrologyCacheBudget budget = HydrologyCacheBudget.forHeap(heap, regionalEnabled); + long total = budget.tileBytes() + budget.columnBytes() + budget.diagnosticBytes() + + budget.routingBytes() + budget.ownerBytes() + budget.edgeBytes() + budget.regionalDraftBytes() + + budget.regionalCoarseBytes() + budget.regionalTerrainBytes() + budget.regionalReachBytes(); + assertTrue(total <= Math.clamp(heap / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE)); + assertTrue(total >= 8L * MEBIBYTE); + assertEquals(budget.tileBytes() / 6L, budget.columnBytes()); + } + } + HydrologyCacheBudget twoGigabytes = HydrologyCacheBudget.forHeap(2048L * MEBIBYTE, true); + assertEquals(96L * MEBIBYTE, twoGigabytes.tileBytes()); + assertEquals(32L * MEBIBYTE, twoGigabytes.ownerBytes()); + assertEquals(32L * MEBIBYTE, twoGigabytes.regionalTerrainBytes()); + HydrologyCacheBudget localOnly = HydrologyCacheBudget.forHeap(2048L * MEBIBYTE, false); + assertEquals(twoGigabytes.tileBytes(), localOnly.tileBytes()); + assertEquals(96L * MEBIBYTE, localOnly.ownerBytes()); + assertEquals(24L * MEBIBYTE, localOnly.routingBytes()); + assertEquals(8L * MEBIBYTE, localOnly.edgeBytes()); + assertEquals(0L, localOnly.regionalDraftBytes() + localOnly.regionalCoarseBytes() + + localOnly.regionalTerrainBytes() + localOnly.regionalReachBytes()); + assertThrows(IllegalArgumentException.class, () -> HydrologyCacheBudget.forHeap(0L, false)); + } + + @Test + @SuppressWarnings("unchecked") + public void disabledRegionalCachesLeaveTheirBudgetForLocalPlanning() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyPlannerSettings.defaults(), + (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); + HydrologyCacheBudget budget = HydrologyCacheBudget.runtime(false); + assertEquals(budget.ownerBytes(), planner.resolvedOwners.policy().eviction().orElseThrow().getMaximum()); + assertEquals(budget.routingBytes(), planner.routingContexts.policy().eviction().orElseThrow().getMaximum()); + assertEquals(budget.edgeBytes(), planner.refinedEdgeCache.policy().eviction().orElseThrow().getMaximum()); + Field field = HydrologyRegionalPlanner.class.getDeclaredField("drafts"); + field.setAccessible(true); + Cache drafts = + (Cache) field.get(planner.regional); + assertEquals(0L, drafts.policy().eviction().orElseThrow().getMaximum()); + drafts.put(new HydrologyTileKey(0, 0), HydrologyRegionalNetwork.EMPTY); + drafts.cleanUp(); + assertEquals(0L, drafts.estimatedSize()); + } + + @Test + public void weightsKeepEntryAndStationLimitsAndSaturateLargeValues() { + assertEquals(64, HydrologyCacheWeights.bounded(1L, 1024L, 16)); + assertEquals(256, HydrologyCacheWeights.bounded(256L, 1024L, 16)); + assertEquals(512, HydrologyCacheWeights.bounded(1L, 1024L, 16, 50, 100)); + assertEquals(Integer.MAX_VALUE, HydrologyCacheWeights.bounded(Long.MAX_VALUE, 1024L, 16)); + Cache cache = Caffeine.newBuilder().maximumWeight(1024L) + .weigher((Integer key, Integer bytes) -> HydrologyCacheWeights.bounded(bytes, 1024L, 16)).build(); + for (int key = 0; key < 64; key++) { + cache.put(key, 1); + } + cache.cleanUp(); + assertTrue(cache.estimatedSize() <= 16L); + cache.put(64, 2048); + cache.cleanUp(); + assertNull(cache.getIfPresent(64)); + assertTrue(cache.policy().eviction().orElseThrow().weightedSize().orElseThrow() <= 1024L); + } + + @Test + public void largeRoutingContextsEvictByBytesBeforeTheEntryLimit() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(70, 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), (x, z) -> terrain); + long budget = HydrologyCacheBudget.runtime().routingBytes(); + assertTrue(planner.routingContexts.policy().eviction().orElseThrow().isWeighted()); + assertEquals(budget, planner.routingContexts.policy().eviction().orElseThrow().getMaximum()); + HydrologyGridNode node = new HydrologyGridNode(0, 0, 0, 0, 0, 1L, terrain); + int nodeCount = (int) (budget / 640L); + SourceRoutingContext context = new SourceRoutingContext( + new HydrologySampledGrid(0, 0, 0, 0, 16, 1, 16, Collections.nCopies(nodeCount, node)), + null, null, List.of()); + for (int index = 0; index < 8; index++) { + planner.routingContexts.put(new HydrologyTileKey(index, 0), context); + } + planner.routingContexts.cleanUp(); + assertTrue(planner.routingContexts.estimatedSize() < 8L); + assertTrue(planner.routingContexts.policy().eviction().orElseThrow().weightedSize().orElseThrow() <= budget); + assertNotNull(planner.routingContexts.asMap().values().iterator().next()); + } + + @Test + public void regionalTreeWeightCountsSharedDownstreamLabelsOnce() { + HydrologyRegionalGraph.Label downstream = new HydrologyRegionalGraph.Label( + new HydrologyRegionalGraph.LabelState(0, 0, 63, 0D, 0D, 0), null); + HydrologyRegionalGraph.Label first = new HydrologyRegionalGraph.Label( + new HydrologyRegionalGraph.LabelState(1, 1, 63, 1D, 1D, 0), downstream); + HydrologyRegionalGraph.Label second = new HydrologyRegionalGraph.Label( + new HydrologyRegionalGraph.LabelState(3, 2, 63, 1D, 1D, 0), downstream); + HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree( + new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of()); + assertEquals(3, tree.retainedLabelCount()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java index 2787e3057..84bcc02ec 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java @@ -143,14 +143,15 @@ public class HydrologyDiagnosticCacheTest { } return fresh; }); - ExecutorService caller = Executors.newSingleThreadExecutor(); + ExecutorService caller = Executors.newFixedThreadPool(2); HydrologyTileCache cache = new HydrologyTileCache(planner, 4); try { Future> first = caller.submit(() -> cache.diagnosticCandidates(key)); assertTrue(started.await(5L, TimeUnit.SECONDS)); cache.clear(); - assertEquals(fresh, cache.diagnosticCandidates(key)); + Future> second = caller.submit(() -> cache.diagnosticCandidates(key)); release.countDown(); + assertEquals(fresh, second.get(5L, TimeUnit.SECONDS)); assertEquals(old, first.get(5L, TimeUnit.SECONDS)); assertEquals(fresh, cache.diagnosticCandidates(key)); assertEquals(2, attempts.get()); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java index 1b7fc4df6..7bad88960 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java @@ -35,6 +35,8 @@ public class HydrologyFallbackParallelismTest { @Test(timeout = 15000) public void publicationRejectionConsumesTheNextCompletedOutletWithoutRebuildingIt() throws Exception { + int parallelism = HydrologyPlanningAdmission.effectiveParallelism(4); + int batchSize = parallelism <= 2 ? 1 : Math.min(parallelism, HydrologyPlanningAdmission.maximumTrials()); AtomicIntegerArray builds = new AtomicIntegerArray(4); Fixture fixture = fixture(outlet -> { builds.incrementAndGet(outlet); @@ -53,23 +55,89 @@ public class HydrologyFallbackParallelismTest { if (result.courses().isEmpty()) { return new CrossTilePublicationAdmission(result, List.of(), false); } - for (int outlet = 0; outlet < 4; outlet++) { - assertEquals("Every trial provider must finish before publication", 1, builds.get(outlet)); - } long course = result.courses().getFirst().id(); + int completed = Math.min(4, Math.ceilDiv((int) (course - 199L), batchSize) * batchSize); + for (int outlet = 0; outlet < 4; outlet++) { + assertEquals("Every admitted trial provider must finish before publication", outlet < completed ? 1 : 0, builds.get(outlet)); + } submitted.add(course); return course == 200L ? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(900L)), true) : new CrossTilePublicationAdmission(result, List.of(), false); } }; - HydrologyOwnerDraft draft = run(fixture, admission); + HydrologyOwnerDraft draft = run(fixture, admission, 4); assertEquals(List.of(200L, 201L), submitted); assertEquals(List.of(201L), draft.result().courses().stream().map(RiverCourse::id).toList()); assertEquals(List.of(200L, 900L, 201L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); for (int outlet = 0; outlet < 4; outlet++) { - assertEquals(1, builds.get(outlet)); + int completed = Math.min(4, Math.ceilDiv(2, batchSize) * batchSize); + assertEquals(outlet < completed ? 1 : 0, builds.get(outlet)); + } + } + + @Test(timeout = 15000) + public void limitedWorkersStopAfterTheFirstAcceptedOutlet() throws Exception { + int[] poolSizes = Runtime.getRuntime().availableProcessors() <= 2 ? new int[]{1, 2, 16} : new int[]{1, 2}; + for (int parallelism : poolSizes) { + AtomicIntegerArray builds = new AtomicIntegerArray(4); + Fixture fixture = fixture(outlet -> builds.incrementAndGet(outlet)); + + HydrologyOwnerDraft draft = run(fixture, null, parallelism); + + assertEquals(List.of(200L), draft.result().courses().stream().map(RiverCourse::id).toList()); + assertEquals(List.of(200L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); + assertEquals(1, builds.get(0)); + for (int outlet = 1; outlet < 4; outlet++) { + assertEquals("Unused outlets must not be compiled", 0, builds.get(outlet)); + } + } + } + + @Test(timeout = 15000) + public void rejectedOutletsAdvanceToTheNextWorkerBoundedBatch() throws Exception { + int batchSize = 1; + AtomicIntegerArray builds = new AtomicIntegerArray(4); + Fixture fixture = fixture(outlet -> { + builds.incrementAndGet(outlet); + if (outlet == 3) { + throw new IllegalStateException("Unused fallback provider failed"); + } + }); + ArrayList submitted = new ArrayList<>(); + CrossTileDraftAdmission admission = new CrossTileDraftAdmission() { + @Override + public void prepare() { + } + + @Override + public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) { + if (result.courses().isEmpty()) { + return new CrossTilePublicationAdmission(result, List.of(), false); + } + long course = result.courses().getFirst().id(); + submitted.add(course); + for (int outlet = 0; outlet < 4; outlet++) { + int completed = Math.min(4, Math.ceilDiv((int) (course - 199L), batchSize) * batchSize); + int expected = outlet < completed ? 1 : 0; + assertEquals("Only the demanded batch may be compiled", expected, builds.get(outlet)); + } + return course < 202L + ? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(course + 700L)), true) + : new CrossTilePublicationAdmission(result, List.of(), false); + } + }; + + HydrologyOwnerDraft draft = run(fixture, admission, 2); + + assertEquals(List.of(200L, 201L, 202L), submitted); + assertEquals(List.of(202L), draft.result().courses().stream().map(RiverCourse::id).toList()); + assertEquals(List.of(200L, 900L, 201L, 901L, 202L), + draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); + for (int outlet = 0; outlet < 4; outlet++) { + int completed = Math.min(4, Math.ceilDiv(3, batchSize) * batchSize); + assertEquals("Rejected trials must not be rebuilt", outlet < completed ? 1 : 0, builds.get(outlet)); } } @@ -95,8 +163,11 @@ public class HydrologyFallbackParallelismTest { try { Future pending = pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, null, false)); assertTrue(failed.await(5, TimeUnit.SECONDS)); - assertTrue(blocked.await(5, TimeUnit.SECONDS)); - assertFalse(pending.isDone()); + if (HydrologyPlanningAdmission.maximumTrials() > 1 + && HydrologyPlanningAdmission.effectiveParallelism(4) > 2) { + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertFalse(pending.isDone()); + } release.countDown(); ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS)); Throwable cause = failure.getCause(); @@ -104,7 +175,8 @@ public class HydrologyFallbackParallelismTest { cause = cause.getCause(); } assertSame(expected, cause); - assertTrue(completed.get()); + assertEquals(HydrologyPlanningAdmission.maximumTrials() > 1 + && HydrologyPlanningAdmission.effectiveParallelism(4) > 2, completed.get()); } finally { release.countDown(); pool.shutdownNow(); @@ -112,8 +184,8 @@ public class HydrologyFallbackParallelismTest { } } - private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission) throws Exception { - ForkJoinPool pool = new ForkJoinPool(4); + private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission, int parallelism) throws Exception { + ForkJoinPool pool = new ForkJoinPool(parallelism); try { return pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, admission, false)).get(10, TimeUnit.SECONDS); } finally { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyLandHeightCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyLandHeightCacheTest.java new file mode 100644 index 000000000..5f690e544 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyLandHeightCacheTest.java @@ -0,0 +1,83 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class HydrologyLandHeightCacheTest { + @Test + public void evictionAndFailuresRetainExactScalarValues() { + HydrologyLandHeightCache cache = new HydrologyLandHeightCache(1); + HydrologyNaturalTerrainSampler sampler = mock(HydrologyNaturalTerrainSampler.class); + AtomicInteger calls = new AtomicInteger(); + when(sampler.sampleLandHeight(anyInt(), anyInt())).thenAnswer(invocation -> { + calls.incrementAndGet(); + int x = invocation.getArgument(0); + return x == Integer.MIN_VALUE ? Double.NaN : -0D; + }); + assertEquals(Double.NaN, cache.sample(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler), 0D); + assertEquals(Double.NaN, cache.sample(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler), 0D); + assertEquals(1, calls.get()); + assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(cache.sample(0, 0, sampler))); + assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(cache.sample(0, 0, sampler))); + assertEquals(2, calls.get()); + assertEquals(Double.NaN, cache.sample(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler), 0D); + assertEquals(3, calls.get()); + when(sampler.sampleLandHeight(7, 11)).thenThrow(new IllegalStateException("retry")).thenReturn(83D); + assertThrows(IllegalStateException.class, () -> cache.sample(7, 11, sampler)); + assertEquals(83D, cache.sample(7, 11, sampler), 0D); + assertEquals(83D, cache.sample(7, 11, sampler), 0D); + } + + @Test + public void parallelAlternativesShareOneMissingSample() throws Exception { + HydrologyLandHeightCache cache = new HydrologyLandHeightCache(64); + HydrologyNaturalTerrainSampler sampler = mock(HydrologyNaturalTerrainSampler.class); + AtomicInteger calls = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(4); + when(sampler.sampleLandHeight(-19, 33)).thenAnswer(invocation -> { + calls.incrementAndGet(); + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return Double.NaN; + }); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + List> results = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + results.add(executor.submit(() -> { + ready.countDown(); + return cache.sample(-19, 33, sampler); + })); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertEquals(1, calls.get()); + release.countDown(); + for (Future result : results) { + assertEquals(Double.NaN, result.get(5, TimeUnit.SECONDS), 0D); + } + assertEquals(1, calls.get()); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java index 77ead63bb..e4777c8df 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java @@ -78,6 +78,90 @@ public class HydrologyPlannerRoutingSamplerTest { assertThrows(IllegalStateException.class, () -> planner.plan(new HydrologyTileKey(0, 0))); } + @Test + public void landHeightMemoizationRetainsMissingSamplesOnlyWithinThePlanningScope() { + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger failedCalls = new AtomicInteger(); + HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new AssertionError("Bank heights do not need a routing grid"); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + throw new AssertionError("Bank height provider already classifies land"); + } + + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + return switch (x) { + case 10 -> HydrologyTerrainSample.openLand(87, 0D, "land"); + case 11 -> HydrologyTerrainSample.ocean(60, "ocean"); + default -> null; + }; + } + + @Override + public double sampleLandHeight(int x, int z) { + heightCalls.incrementAndGet(); + if (x == 4 && failedCalls.incrementAndGet() == 1) { + throw new IllegalStateException("Transient height failure"); + } + return switch (x) { + case 0 -> Double.NaN; + case 1 -> 0D; + case 2 -> Integer.MIN_VALUE; + default -> 83D; + }; + } + }; + HydrologyTerrainSampler detail = (x, z) -> HydrologyTerrainSample.openLand(90, 0D, "land"); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner(23L, settings, detail, natural, + HydrologyGeometrySampler.deterministic(detail), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(detail, settings.seaLevel(), -4096, 4096)); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + try { + for (int pass = 0; pass < 3; pass++) { + assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D); + assertEquals(0D, planner.sampleLandHeight(1, -17), 0D); + assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D); + assertEquals(83D, planner.sampleLandHeight(3, -17), 0D); + } + assertEquals(4, heightCalls.get()); + planner.sampleBasis(10, -17); + planner.sampleBasisWithoutSlope(11, -17); + planner.sampleBasisWithoutSlope(12, -17); + assertEquals(87D, planner.sampleLandHeight(10, -17), 0D); + assertEquals(Double.NaN, planner.sampleLandHeight(11, -17), 0D); + assertEquals(Double.NaN, planner.sampleLandHeight(12, -17), 0D); + assertEquals(4, heightCalls.get()); + assertThrows(IllegalStateException.class, () -> planner.sampleLandHeight(4, -17)); + assertEquals(83D, planner.sampleLandHeight(4, -17), 0D); + assertEquals(83D, planner.sampleLandHeight(4, -17), 0D); + assertEquals(6, heightCalls.get()); + HydrologyLandHeightCache shared = planner.planningSamples.get().fallbackLandHeights(); + for (int trial = 0; trial < 3; trial++) { + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared)); + assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D); + assertEquals(0D, planner.sampleLandHeight(1, -17), 0D); + assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D); + assertEquals(83D, planner.sampleLandHeight(3, -17), 0D); + assertEquals(83D, planner.sampleLandHeight(4, -17), 0D); + } + assertEquals(6, heightCalls.get()); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D); + assertEquals(7, heightCalls.get()); + } finally { + planner.planningSamples.remove(); + } + assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D); + assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D); + assertEquals(9, heightCalls.get()); + } + @Test public void oceanClassificationBypassesNaturalBasisSampling() { AtomicInteger basisCalls = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java index cf81365ff..e62e52f5f 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java @@ -95,6 +95,26 @@ public class HydrologyPlannerTest { return draft.invoke(resolution); } + @Test + public void independentOwnersReleaseCompilerWithoutChangingPublication() { + HydrologyPlanner rootPlanner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN); + CrossTileResolution root = rootPlanner.resolveCrossTileOwner(EARLY_OWNER_TILE); + HydrologyFootprintCompiler rootCompiler = root.draft().footprintCompiler(); + assertNotNull(rootCompiler); + assertFalse(root.draft().result().courses().isEmpty()); + + HydrologyPlanner dependencyPlanner = new HydrologyPlanner(77L, EARLY_OWNER_SETTINGS, EARLY_OWNER_TERRAIN); + CrossTileResolvedOwner dependency = dependencyPlanner.resolveIndependentOwner(EARLY_OWNER_TILE); + assertNull(dependency.draft().footprintCompiler()); + assertEquals(root.draft().withoutFootprintCompiler(), dependency.draft()); + assertEquals(root.observedRejections(), dependency.observedRejections()); + + int materializations = rootCompiler.fullMaterializationCount(); + assertTileContentsEqual(EARLY_OWNER_BASELINE, rootPlanner.crossTile.materializeAcceptedTile(root)); + assertTrue(rootCompiler.fullMaterializationCount() > materializations); + assertTileContentsEqual(EARLY_OWNER_BASELINE, dependencyPlanner.plan(EARLY_OWNER_TILE)); + } + @Test public void collidingRoutingContextKeysCompileIndependently() throws Exception { CountDownLatch entered = new CountDownLatch(1); @@ -251,8 +271,44 @@ public class HydrologyPlannerTest { } } + @Test + public void limitedWorkersSkipUnusedOwnersButResolveDemandedOwners() throws Exception { + HydrologyTileKey ownerKey = new HydrologyTileKey(-1, -1); + List demanded = List.of(new HydrologyTileKey(-2, -2), new HydrologyTileKey(0, 0)); + int[] poolSizes = Runtime.getRuntime().availableProcessors() <= 2 ? new int[]{1, 2, 16} : new int[]{1, 2}; + for (int workers : poolSizes) { + AtomicInteger grids = new AtomicInteger(); + HydrologyPlannerSettings settings = withPeriodTwo(standardSettings(4D, 2D, true, false, List.of())); + HydrologyTerrainSample blocked = blockedTerrain(); + HydrologyTerrainSampler terrain = (x, z) -> blocked; + HydrologyPlanner planner = new HydrologyPlanner(77L, settings, terrain, + new ContextRoutingSampler(blocked, request -> grids.incrementAndGet()), + HydrologyGeometrySampler.deterministic(terrain), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096)); + AutoCloseable admission = earlyAdmission(planner, ownerKey); + try (ForkJoinPool pool = new ForkJoinPool(workers)) { + pool.submit(() -> { + ((CrossTileDraftAdmission) admission).prepare(); + admission.close(); + return null; + }).get(5, TimeUnit.SECONDS); + assertTrue(earlyTasks(admission).isEmpty()); + assertTrue(cachedOwnerKeys(planner).isEmpty()); + assertEquals("Unused owner terrain must remain unsampled", 0, grids.get()); + + List resolved = pool.submit(() -> planner.resolveLowerRankOwners( + demanded, new CrossTileResolutionContext(ownerKey, 64L, 4096), Map.of() + )).get(30, TimeUnit.SECONDS); + assertEquals(demanded, resolved.stream().map(owner -> owner.draft().key()).toList()); + assertEquals(Set.copyOf(demanded), cachedOwnerKeys(planner)); + assertEquals(demanded.size(), grids.get()); + } + } + } + @Test public void earlyOwnersPrioritizeHigherRanksAndSkipRunningOwners() throws Exception { + org.junit.Assume.assumeTrue(Runtime.getRuntime().availableProcessors() > 2); HydrologyPlannerSettings settings = withPeriodTwo(standardSettings(4D, 2D, true, false, List.of())); HydrologyPlanner planner = new HydrologyPlanner(77L, settings, (x, z) -> blockedTerrain()); AutoCloseable admission = earlyAdmission(planner, new HydrologyTileKey(-1, -1)); @@ -287,7 +343,7 @@ public class HydrologyPlannerTest { private final ArrayList submitted = new ArrayList<>(); private CapturingHydrologyPool() { - super(1); + super(3); } @Override @@ -337,7 +393,9 @@ public class HydrologyPlannerTest { } finally { pool.shutdownNow(); } - assertTrue("Expected selected sources to prepare lower-rank owners", observedParallelOwner); + if (HydrologyPlanningAdmission.effectiveParallelism(4) > 2) { + assertTrue("Expected selected sources to prepare lower-rank owners", observedParallelOwner); + } } @Test diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java new file mode 100644 index 000000000..cf6dc5d09 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.testsupport.Await; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class HydrologyPlanningAdmissionIntegrationTest { + @Test(timeout = 15000) + public void separateCachesShareTheProcessPlanningBudget() throws Exception { + ArrayList held = holdRoots(HydrologyPlanningAdmission.maximumRoots() - 1); + ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService callers = Executors.newFixedThreadPool(2); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicInteger active = new AtomicInteger(); + AtomicInteger peak = new AtomicInteger(); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class); + HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + when(firstPlanner.plan(key)).thenAnswer(invocation -> { + peak.accumulateAndGet(active.incrementAndGet(), Math::max); + firstStarted.countDown(); + assertTrue(releaseFirst.await(5L, TimeUnit.SECONDS)); + active.decrementAndGet(); + return tile; + }); + when(secondPlanner.plan(key)).thenAnswer(invocation -> { + peak.accumulateAndGet(active.incrementAndGet(), Math::max); + secondStarted.countDown(); + active.decrementAndGet(); + return tile; + }); + HydrologyTileCache firstCache = new HydrologyTileCache(firstPlanner, 4, workers); + HydrologyTileCache secondCache = new HydrologyTileCache(secondPlanner, 4, workers); + try { + Future first = callers.submit(() -> firstCache.get(key)); + assertTrue(firstStarted.await(5L, TimeUnit.SECONDS)); + Future second = callers.submit(() -> secondCache.get(key)); + awaitLoad(secondCache); + assertFalse(secondStarted.await(150L, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + assertSame(tile, first.get(5L, TimeUnit.SECONDS)); + assertSame(tile, second.get(5L, TimeUnit.SECONDS)); + assertEquals(1, peak.get()); + assertEquals(0, active.get()); + } finally { + releaseFirst.countDown(); + releaseRoots(held); + workers.shutdownNow(); + callers.shutdownNow(); + assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + firstCache.close(); + secondCache.close(); + } + } + + @Test(timeout = 15000) + public void closingAWaitingCacheCancelsAdmissionWithoutPublishingAnEmptyTile() throws Exception { + ArrayList held = holdRoots(HydrologyPlanningAdmission.maximumRoots()); + ExecutorService workers = Executors.newSingleThreadExecutor(); + ExecutorService callers = Executors.newFixedThreadPool(2); + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + try { + Future pending = callers.submit(() -> cache.get(key)); + awaitLoad(cache); + callers.submit(cache::close).get(2L, TimeUnit.SECONDS); + ExecutionException failure = assertThrows(ExecutionException.class, + () -> pending.get(2L, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof CancellationException); + verify(planner, never()).plan(any(HydrologyTileKey.class)); + verify(planner, never()).emptyTile(any(HydrologyTileKey.class)); + assertEquals(0, cache.size()); + releaseRoots(held); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlanner nextPlanner = mock(HydrologyPlanner.class); + when(nextPlanner.plan(key)).thenReturn(tile); + try (HydrologyTileCache next = new HydrologyTileCache(nextPlanner, 4, workers)) { + assertSame(tile, callers.submit(() -> next.get(key)).get(5L, TimeUnit.SECONDS)); + } + } finally { + releaseRoots(held); + workers.shutdownNow(); + callers.shutdownNow(); + assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + + private static ArrayList holdRoots(int count) { + ArrayList held = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + held.add(HydrologyPlanningAdmission.acquireRoot(() -> false)); + } + return held; + } + + private static void releaseRoots(ArrayList held) { + for (HydrologyPlanningAdmission.Permit permit : held) { + permit.close(); + } + held.clear(); + } + + private static void awaitLoad(HydrologyTileCache cache) throws Exception { + Field field = HydrologyTileCache.class.getDeclaredField("loading"); + field.setAccessible(true); + Map loading = (Map) field.get(cache); + Await.until("the cache load to start", Duration.ofSeconds(5L), () -> !loading.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionTest.java new file mode 100644 index 000000000..90a41faed --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionTest.java @@ -0,0 +1,212 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlanningAdmissionTest { + @Test + public void rootLimitAccountsForHeapAndProcessors() { + long gibibyte = 1024L * 1024L * 1024L; + assertEquals(1, HydrologyPlanningAdmission.rootLimit(gibibyte, 32)); + assertEquals(1, HydrologyPlanningAdmission.rootLimit(2L * gibibyte, 32)); + assertEquals(2, HydrologyPlanningAdmission.rootLimit(4L * gibibyte, 32)); + assertEquals(2, HydrologyPlanningAdmission.rootLimit(64L * gibibyte, 2)); + assertEquals(8, HydrologyPlanningAdmission.rootLimit(64L * gibibyte, 32)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlanningAdmission(0)); + } + + @Test + public void speculativeParallelismUsesBothWorkersAndAvailableProcessors() { + int[][] cases = { + {16, 1, 1}, {16, 2, 2}, {16, 4, 4}, {16, 32, 16}, + {1, 16, 1}, {2, 16, 2}, {3, 16, 3}, {4, 16, 4}, + {-1, 16, 1}, {16, 0, 1} + }; + for (int[] parameters : cases) { + assertEquals("pool=" + parameters[0] + " processors=" + parameters[1], parameters[2], + HydrologyPlanningAdmission.effectiveParallelism(parameters[0], parameters[1])); + } + assertEquals(Runtime.getRuntime().availableProcessors(), + HydrologyPlanningAdmission.effectiveParallelism(Integer.MAX_VALUE)); + } + + @Test + public void trialLimitSharesWorkingMemoryBetweenAdmittedRoots() { + long gibibyte = 1024L * 1024L * 1024L; + assertEquals(1, HydrologyPlanningAdmission.trialLimit(1280L * 1024L * 1024L, 1)); + assertEquals(2, HydrologyPlanningAdmission.trialLimit(2L * gibibyte, 1)); + assertEquals(2, HydrologyPlanningAdmission.trialLimit(4L * gibibyte, 2)); + assertEquals(1, HydrologyPlanningAdmission.trialLimit(gibibyte / 2L, 1)); + assertEquals(8, HydrologyPlanningAdmission.trialLimit(64L * gibibyte, 8)); + assertEquals(16, HydrologyPlanningAdmission.trialLimit(Long.MAX_VALUE, 8)); + assertThrows(IllegalArgumentException.class, () -> HydrologyPlanningAdmission.trialLimit(gibibyte, 0)); + } + + @Test(timeout = 15000) + public void independentCallersShareTheSameRootBudget() throws Exception { + HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(2); + AtomicInteger running = new AtomicInteger(); + AtomicInteger peak = new AtomicInteger(); + CountDownLatch firstBatch = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + ExecutorService callers = Executors.newFixedThreadPool(8); + try { + ArrayList> results = new ArrayList<>(); + for (int index = 0; index < 8; index++) { + results.add(callers.submit(() -> { + try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) { + peak.accumulateAndGet(running.incrementAndGet(), Math::max); + firstBatch.countDown(); + await(release); + running.decrementAndGet(); + } + })); + } + assertTrue(firstBatch.await(5L, TimeUnit.SECONDS)); + assertEquals(2, running.get()); + release.countDown(); + for (Future result : results) { + result.get(5L, TimeUnit.SECONDS); + } + assertEquals(2, peak.get()); + assertEquals(0, running.get()); + } finally { + release.countDown(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void waitingRootsLeaveWorkersForNestedPlanning() throws Exception { + HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(1); + ForkJoinPool pool = new ForkJoinPool(2); + CountDownLatch firstRoot = new CountDownLatch(1); + CountDownLatch secondWaiting = new CountDownLatch(1); + CountDownLatch childCompleted = new CountDownLatch(1); + try { + Future> first = pool.submit(() -> { + try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) { + firstRoot.countDown(); + await(secondWaiting); + return HydrologyForkJoin.invokeAll(List.of(() -> { + ForkJoinPool.managedBlock(new ForkJoinPool.ManagedBlocker() { + @Override + public boolean isReleasable() { + return childCompleted.getCount() == 0L; + } + + @Override + public boolean block() { + await(childCompleted); + return true; + } + }); + return 1; + }, () -> { + childCompleted.countDown(); + return 2; + }), null); + } + }); + assertTrue(firstRoot.await(5L, TimeUnit.SECONDS)); + Future second = pool.submit(() -> { + secondWaiting.countDown(); + try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) { + return 3; + } + }); + assertEquals(List.of(1, 2), first.get(5L, TimeUnit.SECONDS)); + assertEquals(Integer.valueOf(3), second.get(5L, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void interruptedWaiterExitsWithoutLeakingOrReleasingAnotherRoot() throws Exception { + HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + AtomicReference waitingThread = new AtomicReference<>(); + CountDownLatch waiting = new CountDownLatch(1); + try (HydrologyPlanningAdmission.Permit held = admission.acquire(() -> false)) { + Future interrupted = caller.submit(() -> { + waitingThread.set(Thread.currentThread()); + waiting.countDown(); + assertThrows(CancellationException.class, () -> admission.acquire(() -> false)); + return Thread.currentThread().isInterrupted(); + }); + assertTrue(waiting.await(5L, TimeUnit.SECONDS)); + waitingThread.get().interrupt(); + assertTrue(interrupted.get(5L, TimeUnit.SECONDS)); + Future next = caller.submit(() -> { + try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) { + return null; + } + }); + assertThrows(TimeoutException.class, () -> next.get(150L, TimeUnit.MILLISECONDS)); + held.close(); + next.get(5L, TimeUnit.SECONDS); + } finally { + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void closingWhileQueuedCancelsWithoutWaitingForTheActiveRoot() throws Exception { + HydrologyPlanningAdmission admission = new HydrologyPlanningAdmission(1); + AtomicBoolean closed = new AtomicBoolean(); + AtomicBoolean entered = new AtomicBoolean(); + CountDownLatch waiting = new CountDownLatch(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) { + Future cancelled = caller.submit(() -> { + waiting.countDown(); + assertThrows(CancellationException.class, () -> { + try (HydrologyPlanningAdmission.Permit unused = admission.acquire(closed::get)) { + entered.set(true); + } + }); + }); + assertTrue(waiting.await(5L, TimeUnit.SECONDS)); + closed.set(true); + cancelled.get(2L, TimeUnit.SECONDS); + assertFalse(entered.get()); + } finally { + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS)); + } + try (HydrologyPlanningAdmission.Permit ignored = admission.acquire(() -> false)) { + assertThrows(CancellationException.class, () -> admission.acquire(() -> true)); + } + } + + private static void await(CountDownLatch latch) { + try { + assertTrue("Timed out awaiting planning", latch.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new AssertionError(interruption); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java index 9279717e3..8c52a9576 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java @@ -15,7 +15,7 @@ import static org.junit.Assert.assertTrue; public class HydrologyRegionalCacheTest { @Test - public void sharedTerrainRetentionIsIndependentOfEachSearchBudget() { + public void searchBudgetDoesNotLimitSubsequentSearchesOrTerrainResults() { AtomicInteger reads = new AtomicInteger(); HydrologyTerrainSampler terrain = (x, z) -> { reads.incrementAndGet(); @@ -29,17 +29,17 @@ public class HydrologyRegionalCacheTest { } assertNull(first.sample(65536, 0)); HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples(); - for (int x = 65536; x < 100000; x++) { + for (int x = 65536; x < 70000; x++) { assertNotNull(second.sample(x, 0)); } - assertEquals(100000, reads.get()); - for (int x = 0; x < 100000; x++) { + assertEquals(70000, reads.get()); + for (int x = 0; x < 70000; x++) { assertEquals(70, refiner.sample(x, 0).naturalHeight()); } - assertEquals(100000, reads.get()); + int readsBeforeClear = reads.get(); refiner.clear(); assertEquals(70, refiner.sample(0, 0).naturalHeight()); - assertEquals(100001, reads.get()); + assertEquals(readsBeforeClear + 1, reads.get()); } @Test diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java index db010c5d4..a9719cee8 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java @@ -9,6 +9,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.ForkJoinPool; @@ -31,6 +33,34 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class HydrologyRegionalSourceParallelismTest { + @Test(timeout = 15000) + public void boundedExecutionPreservesTheWholePreselectedSourceWindow() throws Exception { + int maximumTrials = HydrologyPlanningAdmission.maximumTrials(); + int requiredParallelism = Math.min(4, maximumTrials); + CountDownLatch firstBatch = new CountDownLatch(requiredParallelism); + AtomicInteger active = new AtomicInteger(); + AtomicInteger peak = new AtomicInteger(); + Set visited = ConcurrentHashMap.newKeySet(); + Fixture fixture = fixture(4, source -> { + peak.accumulateAndGet(active.incrementAndGet(), Math::max); + visited.add(source); + firstBatch.countDown(); + try { + await(firstBatch); + return source == 0 || source == 4 ? accepted(source) : rejected(source); + } finally { + active.decrementAndGet(); + } + }); + HydrologyRegionalNetwork network = run(fixture); + + assertEquals(List.of(100L, 104L), network.courses().stream().map(RiverCourse::id).toList()); + assertEquals(Set.of(0, 1, 2, 3, 4, 5, 6, 7), visited); + assertTrue(peak.get() >= requiredParallelism); + assertTrue(peak.get() <= maximumTrials); + assertEquals(0, active.get()); + } + @Test(timeout = 15000) public void acceptsOutletsInSourceOrderWhenTheMiddleOfAWindowSucceeds() throws Exception { Fixture fixture = fixture(4, source -> source == 4 || source == 1 ? accepted(source) : rejected(source)); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java new file mode 100644 index 000000000..515fad0b5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java @@ -0,0 +1,132 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +public class HydrologyRouteHeightProbeTest { + @Test + public void crossDropUsesScalarHeightsIncludingMissingAndExtremeValues() { + HydrologyTerrainSample center = land(87); + HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(20, "ocean"), + land(20), land(100), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE)}; + for (HydrologyTerrainSample side : sides) { + Fixture fixture = fixture((x, z) -> side); + int lowest = side == null || side.ocean() ? 87 : Math.min(87, side.naturalHeight()); + double expected = (double) (87 - lowest) * fixture.planner().settings.routing().valleyPreference() + * HydrologyRouteGeometry.CROSS_DROP_WEIGHT; + double actual = fixture.planner().routeGeometry.crossDropPenalty( + new HydrologyPoint(21, 87, -37), new RouteDirection(3D, 4D), center); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + assertEquals(4, fixture.scalarCalls().get()); + assertEquals(0, fixture.basisCalls().get()); + } + Fixture untouched = fixture((x, z) -> land(20)); + assertEquals(0D, untouched.planner().routeGeometry.crossDropPenalty( + new HydrologyPoint(0, 87, 0), new RouteDirection(0D, 0D), center), 0D); + assertEquals(0, untouched.scalarCalls().get()); + } + + @Test + public void pitChecksRetainMissingAndIntegerDifferenceSemantics() { + int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop(); + HydrologyTerrainSample blocked = spy(land(threshold)); + doReturn(false).when(blocked).transitAllowed(); + HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(threshold, "ocean"), + land(threshold - 1), land(threshold), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE), blocked}; + for (int center : new int[]{0, Integer.MIN_VALUE, Integer.MAX_VALUE}) { + for (HydrologyTerrainSample before : sides) { + for (HydrologyTerrainSample after : sides) { + Fixture fixture = fixture((x, z) -> x < 0 ? before : after); + boolean expected = before != null && !before.ocean() && after != null && !after.ocean() + && before.naturalHeight() - center >= threshold + && after.naturalHeight() - center >= threshold; + RouteCandidate candidate = new RouteCandidate(new HydrologyPoint(0, center, 0), + 0D, 0D, 0D, 0D, 0D, new RouteDirection(1D, 0D), true); + assertEquals(expected, fixture.planner().routePaths.isTerrainPit(candidate)); + assertEquals(2, fixture.scalarCalls().get()); + assertEquals(0, fixture.basisCalls().get()); + } + } + } + } + + @Test + public void crevassesUseScalarsWhileTransitionsStillCheckInteriorPolicy() { + int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop(); + HydrologyTerrainSample blocked = spy(land(80)); + doReturn(false).when(blocked).transitAllowed(); + HydrologyTerrainSample[] terrain = {null, HydrologyTerrainSample.ocean(80, "ocean"), + land(80), land(80 - threshold), land(81 - threshold), land(Integer.MIN_VALUE), + land(Integer.MAX_VALUE), blocked}; + HydrologyPoint start = new HydrologyPoint(0, 80, 0); + HydrologyPoint end = new HydrologyPoint(2, 80, 0); + for (HydrologyTerrainSample endpoint : terrain) { + for (HydrologyTerrainSample middle : terrain) { + boolean endpointsMissing = endpoint == null || endpoint.ocean(); + boolean middleMissing = middle == null || middle.ocean(); + boolean crevasse = endpointsMissing || middleMissing + || endpoint.naturalHeight() - middle.naturalHeight() >= threshold; + Fixture fixture = fixture((x, z) -> x == 1 ? middle : endpoint); + assertEquals(crevasse, fixture.planner().routePaths.crossesTerrainCrevasse(start, end)); + assertEquals(endpointsMissing ? 2 : 3, fixture.scalarCalls().get()); + assertEquals(0, fixture.basisCalls().get()); + + fixture.scalarCalls().set(0); + assertEquals(!crevasse && middle.transitAllowed(), + fixture.planner().routePaths.traversableTerrainTransition(start, end)); + assertEquals(2, fixture.scalarCalls().get()); + assertEquals(endpointsMissing ? 0 : 1, fixture.basisCalls().get()); + } + } + Fixture adjacent = fixture((x, z) -> null); + assertFalse(adjacent.planner().routePaths.crossesTerrainCrevasse(start, new HydrologyPoint(1, 80, 0))); + assertEquals(0, adjacent.scalarCalls().get()); + } + + private static HydrologyTerrainSample land(int height) { + return HydrologyTerrainSample.openLand(height, 0D, "land"); + } + + private static Fixture fixture(HydrologyTerrainSampler terrain) { + AtomicInteger basisCalls = new AtomicInteger(); + AtomicInteger scalarCalls = new AtomicInteger(); + HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new AssertionError("Height probes do not need a routing grid."); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + throw new AssertionError("Scalar probes already classify land."); + } + + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + basisCalls.incrementAndGet(); + return terrain.sample(x, z); + } + + @Override + public double sampleLandHeight(int x, int z) { + scalarCalls.incrementAndGet(); + HydrologyTerrainSample sampled = terrain.sample(x, z); + return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight(); + } + }; + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner(91L, settings, terrain, natural, + HydrologyGeometrySampler.deterministic(terrain), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096)); + return new Fixture(planner, basisCalls, scalarCalls); + } + + private record Fixture(HydrologyPlanner planner, AtomicInteger basisCalls, AtomicInteger scalarCalls) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java index 0cc3b3667..76bdc77ee 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java @@ -19,12 +19,12 @@ public class HydrologyRouteTurnCacheTest { @Test public void repeatedRefinementMatchesExhaustiveUncachedRoutes() throws Exception { Random random = new Random(712319L); - Solver solver = new Solver(); for (int scenario = 0; scenario < 40; scenario++) { + Solver solver = new Solver(); int count = 4 + random.nextInt(4); HydrologyPoint[][] points = new HydrologyPoint[count][]; for (int layer = 0; layer < count; layer++) { - points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 3]; + points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 1 + random.nextInt(4)]; for (int candidate = 0; candidate < points[layer].length; candidate++) { points[layer][candidate] = new HydrologyPoint(layer * 12 + random.nextInt(5), 80, candidate * 9 + random.nextInt(7)); @@ -41,6 +41,21 @@ public class HydrologyRouteTurnCacheTest { } } + @Test + public void twoLayerRefinementResetsReusedCostsAndKeepsFiniteRoutes() throws Exception { + Random random = new Random(173L); + HydrologyPoint[][] points = { + {new HydrologyPoint(0, 80, 0), new HydrologyPoint(0, 80, 4)}, + {new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 4)} + }; + Solver solver = new Solver(); + double[][][][] cache = new double[2][][][]; + for (int refinement = 0; refinement < 32; refinement++) { + Fixture fixture = fixture(points, random); + assertArrayEquals(exhaustive(fixture, 75D), solver.solve(fixture, 75D, cache)); + } + } + @Test public void straightTiesAndDegenerateSegmentsKeepOriginalFirstChoice() throws Exception { HydrologyPoint[][] points = { @@ -199,6 +214,7 @@ public class HydrologyRouteTurnCacheTest { private final Object tangent; private final Method solve; private final Method indices; + private HydrologyRouteGeometry.CurvatureWorkspace workspace; private Solver() throws Exception { Class direction = RouteDirection.class; @@ -210,7 +226,8 @@ public class HydrologyRouteTurnCacheTest { double.class, double.class, double.class, direction, boolean.class); candidateConstructor.setAccessible(true); solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class, - double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class); + double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class, + HydrologyRouteGeometry.CurvatureWorkspace.class); solve.setAccessible(true); indices = solve.getReturnType().getDeclaredMethod("indices"); indices.setAccessible(true); @@ -227,8 +244,11 @@ public class HydrologyRouteTurnCacheTest { } layers.add(candidates); } + if (workspace == null) { + workspace = new HydrologyRouteGeometry.CurvatureWorkspace(layers); + } return (int[]) indices.invoke(solve.invoke(geometry, layers, 12D, maximumTurn, 12D, - fixture.penalties(), fixture.transitions(), cache)); + fixture.penalties(), fixture.transitions(), cache, workspace)); } } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java index 65c72eca7..b6dad9021 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java @@ -87,6 +87,93 @@ public class HydrologySourceSpacingTest { } } + @Test + public void lowerPriorityHaloSamplesDoNotBuildNeighbourOwnerRouting() { + HydrologyPlanner planner = planner(); + HydrologySampledGrid grid = haloGrid(3D, 1D); + HydrologyRoutingPlan routing = routing(grid, Map.of()); + HashMap contexts = new HashMap<>(); + contexts.put(TILE, new SourceRoutingContext(grid, routing, routing, List.of())); + + assertTrue(planner.sourcePlanner.globallyAdmittedSource(grid.nodeAt(5, 5), + planner.settings.surface().sources(), HydrologySourcePlanner.SURFACE_SOURCE_SALT, + true, contexts, new HashMap<>())); + + assertEquals(Set.of(TILE), contexts.keySet()); + assertEquals(0L, planner.routingContexts.estimatedSize()); + } + + @Test + public void higherPriorityHaloSourcesStillDemandTheirOwnerRouting() { + HydrologyPlanner planner = planner(); + HydrologySampledGrid grid = haloGrid(1D, 3D); + HydrologyRoutingPlan routing = routing(grid, Map.of()); + SourceRoutingContext context = new SourceRoutingContext(grid, routing, routing, List.of()); + HydrologyTileKey westernOwner = new HydrologyTileKey(-1, 0); + planner.routingContexts.put(westernOwner, context); + HashMap contexts = new HashMap<>(); + contexts.put(TILE, context); + + assertFalse(planner.sourcePlanner.globallyAdmittedSource(grid.nodeAt(5, 5), + planner.settings.surface().sources(), HydrologySourcePlanner.SURFACE_SOURCE_SALT, + true, contexts, new HashMap<>())); + + assertEquals(Set.of(TILE, westernOwner), contexts.keySet()); + } + + @Test + public void requestDependentSamplersAlwaysUseTheOwnerGridForSourcePriority() { + HydrologyRoutingTerrainSampler routingSampler = new HydrologyRoutingTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new AssertionError("The owner grids are already cached"); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + throw new AssertionError("Source spacing uses sampled routing grids"); + } + }; + HydrologyPlannerSettings settings = planner().settings; + HydrologyTerrainSampler terrain = (x, z) -> land(HIGH, 0D); + HydrologyPlanner planner = new HydrologyPlanner(1337L, settings, terrain, routingSampler, + HydrologyGeometrySampler.deterministic(terrain), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096)); + HydrologySampledGrid primaryGrid = haloGrid(1D, 0D); + HydrologySampledGrid ownerGrid = haloGrid(1D, 3D); + HydrologyRoutingPlan primaryRouting = routing(primaryGrid, Map.of()); + HydrologyRoutingPlan ownerRouting = routing(ownerGrid, Map.of()); + SourceRoutingContext primaryContext = new SourceRoutingContext(primaryGrid, primaryRouting, primaryRouting, List.of()); + SourceRoutingContext ownerContext = new SourceRoutingContext(ownerGrid, ownerRouting, ownerRouting, List.of()); + for (HydrologyTileKey key : List.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(-1, 0), + new HydrologyTileKey(0, -1))) { + planner.routingContexts.put(key, ownerContext); + } + HashMap contexts = new HashMap<>(); + contexts.put(TILE, primaryContext); + + assertFalse(routingSampler.supportsSharedGridSamples()); + assertFalse(planner.sourcePlanner.globallyAdmittedSource(primaryGrid.nodeAt(5, 5), + settings.surface().sources(), HydrologySourcePlanner.SURFACE_SOURCE_SALT, + true, contexts, new HashMap<>())); + assertTrue(contexts.containsKey(new HydrologyTileKey(-1, 0))); + } + + private static HydrologySampledGrid haloGrid(double centerWeight, double westernWeight) { + ArrayList nodes = new ArrayList<>(WIDTH * WIDTH); + for (int gridZ = 0; gridZ < WIDTH; gridZ++) { + for (int gridX = 0; gridX < WIDTH; gridX++) { + int index = gridZ * WIDTH + gridX; + int x = (gridX - 5) * SPACING; + int z = (gridZ - 5) * SPACING; + double weight = z != 0 ? 0D : x == 0 ? centerWeight : x == -SPACING ? westernWeight : 0D; + nodes.add(new HydrologyGridNode(index, gridX, gridZ, x, z, index + 1L, land(HIGH, weight))); + } + } + return new HydrologySampledGrid(-5 * SPACING, -5 * SPACING, 0, 0, + HydrologyPlannerSettings.defaults().routing().tileSize(), WIDTH, SPACING, List.copyOf(nodes)); + } + private static Field denseField() { HashMap weights = new HashMap<>(); for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java index 2ce22c1d9..75cfa74d6 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java @@ -4,6 +4,7 @@ import org.junit.Test; import java.util.NavigableSet; import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -31,6 +32,65 @@ public class HydrologySurfaceBankProbeTest { } } + @Test + public void bankHeightQueriesPreserveExactPenaltiesWithoutBankPolicySamples() { + HydrologyTerrainSampler terrain = (x, z) -> x + z == 7 ? null + : x < -3 ? HydrologyTerrainSample.ocean(60, "ocean") + : HydrologyTerrainSample.openLand(80 + Math.floorMod(x * 3 + z * 7, 31), 0D, "land"); + AtomicInteger basisCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() { + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + basisCalls.incrementAndGet(); + return terrain.sample(x, z); + } + + @Override + public double sampleLandHeight(int x, int z) { + heightCalls.incrementAndGet(); + HydrologyTerrainSample sampled = terrain.sample(x, z); + return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight(); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new AssertionError("Bank probes do not require a routing grid"); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + throw new AssertionError("Bank probes only require land heights"); + } + }; + for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) { + HydrologyPlannerSettings settings = settings(3D); + HydrologyPlanner reference = new HydrologyPlanner(91L, settings, terrain); + HydrologyPlanner optimized = new HydrologyPlanner(91L, settings, terrain, natural, + HydrologyGeometrySampler.deterministic(terrain), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096)); + HydrologyPoint point = new HydrologyPoint(0, 80, 0); + RouteCandidate candidate = new RouteCandidate(point, 0D, 0D, 0D, 0D, 0D, + new RouteDirection(StrictMath.cos(angle), StrictMath.sin(angle)), true); + basisCalls.set(0); + heightCalls.set(0); + double expected = reference.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate); + double actual = optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate); + assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual)); + assertEquals(1, basisCalls.get()); + assertTrue(heightCalls.get() > 0); + optimized.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + try { + assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D); + int firstPassHeightCalls = heightCalls.get(); + assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D); + assertEquals(firstPassHeightCalls, heightCalls.get()); + } finally { + optimized.planningSamples.remove(); + } + } + } + /** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */ private int innerRing() { return (int) StrictMath.ceil( diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java index f53fa09cf..cfbfa3a18 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java @@ -7,8 +7,52 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; public class HydrologySurfaceRouteGeometryTest { + @Test + public void lazyTransitionGeometryIsReusedUntilResolvedHeightsChange() { + HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), + (x, z) -> { + throw new AssertionError("Geometry lookup must not sample terrain."); + }); + RouteCandidate previous = candidate(0D, 0D); + RouteCandidate current = spy(candidate(4D, 0D)); + HydrologyRouteGeometry.CurvatureWorkspace workspace = new HydrologyRouteGeometry.CurvatureWorkspace( + List.of(List.of(previous), List.of(current))); + double expected = planner.routePaths.routeTransitionGeometryCost(previous, current, 4D); + clearInvocations(current); + + for (int iteration = 0; iteration < 8; iteration++) { + double actual = planner.routeGeometry.resolvedTransitionCost( + previous, current, 4D, Double.NaN, workspace, 1, 0, 0); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + verify(current, times(1)).offset(); + + RouteCandidate raised = spy(new RouteCandidate(new HydrologyPoint(4, 91, 0), + current.continuousX(), current.continuousZ(), current.offset(), current.localScore(), + current.terrainScore(), current.tangent(), true)); + double raisedExpected = planner.routePaths.routeTransitionGeometryCost(previous, raised, 4D); + assertTrue(raisedExpected > expected); + clearInvocations(raised); + for (int iteration = 0; iteration < 8; iteration++) { + double actual = planner.routeGeometry.resolvedTransitionCost( + previous, raised, 4D, Double.NaN, workspace, 1, 0, 0); + assertEquals(Double.doubleToRawLongBits(raisedExpected), Double.doubleToRawLongBits(actual)); + } + verify(raised, times(1)).offset(); + assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits( + planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, -0D, + workspace, 1, 0, 0))); + assertEquals(Double.POSITIVE_INFINITY, + planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, + Double.POSITIVE_INFINITY, workspace, 1, 0, 0), 0D); + } + @Test public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() { HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java index b19df82c8..d99f6ecb3 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java @@ -50,6 +50,181 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; public class HydrologyTileCacheTest { + @Test + public void weightedTileCapacityStopsSpeculationWithoutBlockingDemand() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + RiverFootprint footprint = mock(RiverFootprint.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.footprint()).thenReturn(footprint); + when(footprint.size()).thenReturn((int) (HydrologyCacheBudget.runtime().tileBytes() * 3L / 5L / 64L)); + when(footprint.columns()).thenReturn(Map.of()); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run); + int tileSize = emptySettings().routing().tileSize(); + cache.prefetchArea(0, 0, tileSize * 4 - 1, 0, 0, 0); + cache.prefetchArea(tileSize, 0, tileSize * 4 - 1, 0, tileSize, 0); + verify(planner, times(1)).plan(any(HydrologyTileKey.class)); + assertSame(tile, cache.get(new HydrologyTileKey(1, 0))); + verify(planner, times(2)).plan(any(HydrologyTileKey.class)); + assertEquals(1, cache.size()); + cache.close(); + } + + @Test(timeout = 10000L) + public void nonblockingQueriesComposeOverBudgetTileSnapshotsOnOneWorker() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + RiverFootprint footprint = mock(RiverFootprint.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + when(tile.footprint()).thenReturn(footprint); + when(footprint.size()).thenReturn((int) (HydrologyCacheBudget.runtime().tileBytes() * 3L / 5L / 64L)); + when(footprint.columns()).thenReturn(Map.of()); + ExecutorService worker = Executors.newSingleThreadExecutor(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, worker, () -> true); + cache.setNeighbourPrefetchEnabled(false); + try { + cache.get(new HydrologyTileKey(10, 10)); + assertEquals(1, cache.size()); + assertFalse(cache.columnSnapshot(0, 0).available()); + worker.submit(() -> {}).get(5L, TimeUnit.SECONDS); + + assertEquals(1, cache.size()); + assertTrue(cache.isPlanned(0, 0)); + assertTrue(cache.columnSnapshot(0, 0).available()); + assertTrue(cache.columnAt(0, 0).isEmpty()); + verify(planner, times(5)).plan(any(HydrologyTileKey.class)); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test + public void nonblockingQueryQueueIsBoundedAndDeduplicatesChunks() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 2, queued::add, () -> true); + cache.setNeighbourPrefetchEnabled(false); + for (int query = 0; query < 100; query++) { + assertFalse(cache.columnSnapshot(40, 40).available()); + } + for (int query = 1; query < 100; query++) { + assertFalse(cache.columnSnapshot(query * 64 + 40, 40).available()); + } + assertEquals(1, queued.size()); + verify(planner, never()).plan(any(HydrologyTileKey.class)); + drainPrefetchTasks(queued); + verify(planner, times(2)).plan(any(HydrologyTileKey.class)); + assertTrue(cache.columnSnapshot(40, 40).available()); + assertTrue(cache.columnSnapshot(104, 40).available()); + cache.close(); + } + + @Test(timeout = 10000L) + public void owningWorkerClaimsCompositionQueuedBehindIt() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ForkJoinPool worker = new ForkJoinPool(1, ForkJoinPool.defaultForkJoinWorkerThreadFactory, + null, false, 1, 1, 1, pool -> true, 60L, TimeUnit.SECONDS); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, worker); + cache.setNeighbourPrefetchEnabled(false); + try { + Future> result = worker.submit(() -> { + started.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return cache.columnAt(40, 40); + }); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + assertFalse(cache.isPlanned(40, 40)); + release.countDown(); + assertTrue(result.get(5L, TimeUnit.SECONDS).isEmpty()); + worker.submit(() -> {}).get(5L, TimeUnit.SECONDS); + assertTrue(cache.isPlanned(40, 40)); + verify(planner, times(1)).plan(any(HydrologyTileKey.class)); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test(timeout = 10000L) + public void displacedCompositionTaskDoesNotCancelItsClaimedFuture() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + CountDownLatch planning = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation -> { + planning.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return tile; + }); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + ExecutorService callers = Executors.newFixedThreadPool(2); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add); + cache.setNeighbourPrefetchEnabled(false); + try { + assertFalse(cache.isPlanned(40, 40)); + Runnable displaced = queued.remove(); + Future> owner = callers.submit(() -> cache.columnAt(40, 40)); + assertTrue(planning.await(5L, TimeUnit.SECONDS)); + Future> waiter = callers.submit(() -> cache.columnAt(40, 40)); + assertThrows(TimeoutException.class, () -> waiter.get(100L, TimeUnit.MILLISECONDS)); + displaced.run(); + assertThrows(TimeoutException.class, () -> waiter.get(100L, TimeUnit.MILLISECONDS)); + release.countDown(); + assertTrue(owner.get(5L, TimeUnit.SECONDS).isEmpty()); + assertTrue(waiter.get(5L, TimeUnit.SECONDS).isEmpty()); + verify(planner, times(1)).plan(any(HydrologyTileKey.class)); + } finally { + release.countDown(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test + public void clearingAndClosingCancelQueuedNonblockingComposition() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 2, queued::add, () -> true); + cache.setNeighbourPrefetchEnabled(false); + assertFalse(cache.columnSnapshot(40, 40).available()); + cache.clear(); + assertFalse(cache.columnSnapshot(104, 40).available()); + drainPrefetchTasks(queued); + verify(planner, never()).plan(new HydrologyTileKey(0, 0)); + verify(planner, times(1)).plan(new HydrologyTileKey(1, 0)); + assertTrue(cache.columnSnapshot(104, 40).available()); + assertFalse(cache.columnSnapshot(168, 40).available()); + cache.close(); + drainPrefetchTasks(queued); + verify(planner, never()).plan(new HydrologyTileKey(2, 0)); + assertEquals(0, cache.size()); + } + @Test public void interruptedWaiterExitsWithoutCancellingTheSharedPlan() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); @@ -957,17 +1132,18 @@ public class HydrologyTileCacheTest { assertTrue(order.subList(0, 4).containsAll(List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(0, 1), new HydrologyTileKey(1, 1)))); } @Test - public void tileBatchesPlanMissingTilesConcurrentlyOnThePrefetchExecutor() throws Exception { + public void tileBatchesUseTheAvailableRootBudgetOnThePrefetchExecutor() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); AtomicInteger inFlight = new AtomicInteger(); AtomicInteger peak = new AtomicInteger(); - CountDownLatch allStarted = new CountDownLatch(4); + int admitted = Math.min(4, HydrologyPlanningAdmission.maximumRoots()); + CountDownLatch allStarted = new CountDownLatch(admitted); doAnswer(invocation -> { peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max); allStarted.countDown(); - assertTrue("every batch member should plan at the same time", allStarted.await(5, TimeUnit.SECONDS)); + assertTrue("the admitted batch should plan concurrently", allStarted.await(5, TimeUnit.SECONDS)); inFlight.decrementAndGet(); return tile; }).when(planner).plan(any(HydrologyTileKey.class)); @@ -984,7 +1160,7 @@ public class HydrologyTileCacheTest { List tiles = cache.tiles(keys); assertEquals(4, tiles.size()); - assertEquals(4, peak.get()); + assertEquals(admitted, peak.get()); verify(planner, times(4)).plan(any(HydrologyTileKey.class)); assertEquals(4, cache.size()); } finally { @@ -1007,7 +1183,7 @@ public class HydrologyTileCacheTest { private void assertConcurrentTilePlanning(List keys) throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyTile tile = mock(HydrologyTile.class); - CountDownLatch started = new CountDownLatch(keys.size()); + CountDownLatch started = new CountDownLatch(Math.min(keys.size(), HydrologyPlanningAdmission.maximumRoots())); CountDownLatch release = new CountDownLatch(1); doAnswer(invocation -> { started.countDown(); @@ -1019,7 +1195,7 @@ public class HydrologyTileCacheTest { try { HydrologyTileCache cache = new HydrologyTileCache(planner, 64, executor); Future> result = caller.submit(() -> cache.tiles(keys)); - assertTrue("colliding keys must start before either plan completes", started.await(5, TimeUnit.SECONDS)); + assertTrue("colliding keys must use the available root budget", started.await(5, TimeUnit.SECONDS)); release.countDown(); assertEquals(keys.size(), result.get(5, TimeUnit.SECONDS).size()); for (HydrologyTileKey key : keys) { @@ -1138,8 +1314,9 @@ public class HydrologyTileCacheTest { Future oldResult = callers.submit(() -> cache.get(key)); assertTrue(started.await(5, TimeUnit.SECONDS)); cache.clear(); - assertSame(newTile, callers.submit(() -> cache.get(key)).get(5, TimeUnit.SECONDS)); + Future newResult = callers.submit(() -> cache.get(key)); release.countDown(); + assertSame(newTile, newResult.get(5, TimeUnit.SECONDS)); assertSame(oldTile, oldResult.get(5, TimeUnit.SECONDS)); assertSame(newTile, cache.get(key)); assertEquals(2, calls.get()); @@ -1502,17 +1679,27 @@ public class HydrologyTileCacheTest { List keys = List.of( new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0)); Future> result = caller.submit(() -> cache.tiles(keys)); - Runnable firstDemand = queued.poll(5, TimeUnit.SECONDS); - Runnable secondDemand = queued.poll(5, TimeUnit.SECONDS); - assertNotNull(firstDemand); - assertNotNull(secondDemand); - + Field demandField = HydrologyTileCache.class.getDeclaredField("demandBatches"); + Field queueField = HydrologyTileCache.class.getDeclaredField("prefetchQueue"); + demandField.setAccessible(true); + queueField.setAccessible(true); + Object demandLock = queueField.get(cache); + Await.until("the demand batch to register", Duration.ofSeconds(5L), () -> { + synchronized (demandLock) { + try { + return demandField.getInt(cache) > 0; + } catch (IllegalAccessException failure) { + throw new IllegalStateException(failure); + } + } + }); initialPrefetch.run(); - assertTrue(queued.isEmpty()); assertSame(tile, cache.get(new HydrologyTileKey(0, 0))); - assertTrue(queued.isEmpty()); + Runnable firstDemand = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(firstDemand); firstDemand.run(); - assertTrue(queued.isEmpty()); + Runnable secondDemand = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(secondDemand); secondDemand.run(); assertEquals(3, result.get(5, TimeUnit.SECONDS).size()); Runnable resumedPrefetch = queued.poll(5, TimeUnit.SECONDS); @@ -1788,12 +1975,8 @@ public class HydrologyTileCacheTest { HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); - List queued = new ArrayList<>(); - HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add, () -> true); - assertFalse(cache.columnSnapshot(40, 40).available()); - for (Runnable task : List.copyOf(queued)) { - task.run(); - } + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, null, () -> true); + cache.get(new HydrologyTileKey(0, 0)); AtomicBoolean evicted = new AtomicBoolean(); when(tile.columnAt(anyInt(), anyInt())).thenAnswer(invocation -> { if (evicted.compareAndSet(false, true)) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java index 64d5df685..b368f0082 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java @@ -1,6 +1,14 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.generation.hydrology.cave.CavePosition; +import art.arcane.iris.generation.hydrology.cave.CaveVoxel; +import art.arcane.iris.generation.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode; +import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -17,6 +25,9 @@ import java.util.List; import java.util.Set; import java.util.Map; import java.util.OptionalLong; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -50,6 +61,60 @@ public class StudioHydrologyTileStoreTest { assertThrows(UnsupportedOperationException.class, () -> restored.regionalCourseIds().clear()); } + @Test + public void roundTripsNonemptyCompactCavePlan() throws Exception { + CavePosition wet = new CavePosition(0, 20, 0); + CavePosition dry = new CavePosition(0, 21, 0); + CavePosition guard = new CavePosition(Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE); + CavePosition boundary = new CavePosition(Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); + LinkedHashMap actions = new LinkedHashMap<>(); + actions.put(guard, HydrologyCaveAction.SEAL_GUARD); + actions.put(dry, HydrologyCaveAction.DRY_AIR); + actions.put(wet, HydrologyCaveAction.WET_SOURCE); + LinkedHashMap preconditions = new LinkedHashMap<>(); + preconditions.put(wet, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + preconditions.put(boundary, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true)); + preconditions.put(guard, new CaveVoxelPrecondition(CaveVoxel.LAVA, false)); + preconditions.put(dry, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false)); + HydrologyCavePlan plan = new HydrologyCavePlan( + new HydrologyCaveSource(4L, wet, dry, 20, HydrologyCaveMode.CLOSED_COMPONENT), + HydrologyCaveRejection.NONE, actions, preconditions, OptionalLong.empty()); + HydraulicSegment segment = new HydraulicSegment(3L, 4L, HydrologyFeatureType.UNDERGROUND_POOL, + 20, 20, 1, 1, false, false, List.of(new HydrologyPoint(0, 20, 0)), + new HydraulicChannelProfile(new double[]{1D}, new double[]{1D})); + RiverCourse course = new RiverCourse(4L, RiverCourseType.SEA_CAVE, OptionalLong.empty(), + OptionalLong.empty(), "default", 1, List.of(), List.of(segment)); + HydrologyFeatureRef feature = new HydrologyFeatureRef(5L, HydrologyFeatureType.UNDERGROUND_POOL, + 4L, 3L, 0, 20, 0, 0, 0, true); + HydrologyColumnLayer layer = new HydrologyColumnLayer(feature, 19, 20, 21, + true, false, false, true, false, false, true, true, false, + "default", "plains", "plains", "plains", "plains", "plains"); + HydrologyColumnSample column = new HydrologyColumnSample(0, 0, 80, 63, false, "plains", List.of(layer)); + HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64, + List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(), + new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column))); + StudioHydrologyTileStore store = store(); + store.save(original); + HydrologyTile restored = store.load(original.key()).orElseThrow(); + assertEquals(original, restored); + HydrologyCavePlan restoredPlan = restored.cavePlans().getFirst(); + assertEquals(actions, restoredPlan.actions()); + assertEquals(preconditions, restoredPlan.baselinePreconditions()); + Comparator persistedOrder = Comparator.comparingInt(CavePosition::x) + .thenComparingInt(CavePosition::z).thenComparingInt(CavePosition::y); + ArrayList expectedActions = new ArrayList<>(actions.keySet()); + expectedActions.sort(persistedOrder); + ArrayList expectedPreconditions = new ArrayList<>(preconditions.keySet()); + expectedPreconditions.sort(persistedOrder); + assertEquals(expectedActions, new ArrayList<>(restoredPlan.actions().keySet())); + assertEquals(expectedPreconditions, new ArrayList<>(restoredPlan.baselinePreconditions().keySet())); + assertThrows(UnsupportedOperationException.class, () -> restoredPlan.actions().clear()); + assertThrows(UnsupportedOperationException.class, () -> restoredPlan.baselinePreconditions().remove(boundary)); + ArrayList published = new ArrayList<>(); + restoredPlan.forEachActionIn(0, 0, 16, 16, (position, action) -> published.add(position)); + assertEquals(List.of(wet, dry), published); + } + @Test public void entryRequiresRegionalOwnershipMetadata() throws Exception { HydrologyTile original = tile(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlanTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlanTest.java index 73e1bbaef..d7f8ef8cf 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlanTest.java @@ -7,9 +7,14 @@ import java.util.OptionalLong; import java.util.LinkedHashMap; import java.util.ArrayList; import java.util.List; +import java.util.Collections; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Random; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -84,6 +89,134 @@ public class HydrologyCavePlanTest { assertEquals(List.of(inside, preconditionOnly), visitedPreconditions); } + @Test + public void compactMapsPreserveAllCoordinateBitsAndIndependentOrders() { + Random random = new Random(718622L); + ArrayList positions = new ArrayList<>(); + for (int x : new int[]{Integer.MIN_VALUE, -1, 0, Integer.MAX_VALUE}) { + for (int y : new int[]{Integer.MIN_VALUE, -1, 0, Integer.MAX_VALUE}) { + for (int z : new int[]{Integer.MIN_VALUE, -1, 0, Integer.MAX_VALUE}) { + positions.add(new CavePosition(x, y, z)); + } + } + } + for (int index = 0; index < 4096; index++) { + positions.add(new CavePosition(random.nextInt(), random.nextInt(), random.nextInt())); + } + LinkedHashMap actions = new LinkedHashMap<>(); + LinkedHashMap preconditions = new LinkedHashMap<>(); + HydrologyCaveAction[] actionTypes = HydrologyCaveAction.values(); + CaveVoxel[] voxels = CaveVoxel.values(); + for (int index = 0; index < positions.size(); index++) { + if (index % 3 != 0) { + actions.put(positions.get(index), actionTypes[index % actionTypes.length]); + } + } + Collections.shuffle(positions, random); + for (int index = 0; index < positions.size(); index++) { + preconditions.put(positions.get(index), new CaveVoxelPrecondition(voxels[index % voxels.length], index % 2 == 0)); + } + HydrologyCavePlan plan = new HydrologyCavePlan(source(), HydrologyCaveRejection.NONE, + actions, preconditions, OptionalLong.empty()); + assertEquals(actions, plan.actions()); + assertEquals(plan.actions(), actions); + assertEquals(actions.hashCode(), plan.actions().hashCode()); + assertEquals(preconditions, plan.baselinePreconditions()); + assertEquals(plan.baselinePreconditions(), preconditions); + assertEquals(preconditions.hashCode(), plan.baselinePreconditions().hashCode()); + assertEquals(new ArrayList<>(actions.entrySet()), new ArrayList<>(plan.actions().entrySet())); + assertEquals(new ArrayList<>(preconditions.entrySet()), new ArrayList<>(plan.baselinePreconditions().entrySet())); + assertEquals(new ArrayList<>(actions.values()), new ArrayList<>(plan.actions().values())); + assertEquals(new ArrayList<>(preconditions.values()), new ArrayList<>(plan.baselinePreconditions().values())); + assertTrue(plan.allPreconditions((position, baseline) -> baseline.equals(preconditions.get(position)))); + LinkedHashMap visitedActions = new LinkedHashMap<>(); + LinkedHashMap visitedPreconditions = new LinkedHashMap<>(); + plan.forEachAction(visitedActions::put); + plan.forEachPrecondition(visitedPreconditions::put); + assertEquals(new ArrayList<>(actions.entrySet()), new ArrayList<>(visitedActions.entrySet())); + assertEquals(new ArrayList<>(preconditions.entrySet()), new ArrayList<>(visitedPreconditions.entrySet())); + assertNull(plan.actions().get("absent")); + assertNull(plan.baselinePreconditions().get(new CavePosition(42, 43, 44))); + actions.clear(); + preconditions.clear(); + assertEquals(visitedActions, plan.actions()); + assertEquals(visitedPreconditions, plan.baselinePreconditions()); + assertThrows(UnsupportedOperationException.class, () -> plan.actions().entrySet().iterator().next().setValue(HydrologyCaveAction.DRY_AIR)); + assertThrows(UnsupportedOperationException.class, () -> plan.baselinePreconditions().values().clear()); + Iterator iterator = plan.actions().keySet().iterator(); + iterator.next(); + assertThrows(UnsupportedOperationException.class, iterator::remove); + while (iterator.hasNext()) { + iterator.next(); + } + assertThrows(NoSuchElementException.class, iterator::next); + } + + @Test + public void spatialQueriesPreserveExtremeCoordinatesAndChunkOrder() { + List positions = List.of( + new CavePosition(Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE), + new CavePosition(Integer.MAX_VALUE - 1, Integer.MIN_VALUE, Integer.MAX_VALUE - 1), + new CavePosition(16, 1, 16), + new CavePosition(-1, 2, 0), + new CavePosition(0, 3, -1), + new CavePosition(1, 4, 1), + new CavePosition(1, 5, 1) + ); + LinkedHashMap actions = new LinkedHashMap<>(); + LinkedHashMap preconditions = new LinkedHashMap<>(); + CaveVoxelPrecondition baseline = new CaveVoxelPrecondition(CaveVoxel.SOLID, false); + for (CavePosition position : positions) { + actions.put(position, HydrologyCaveAction.WET_SOURCE); + } + for (CavePosition position : positions.reversed()) { + preconditions.put(position, baseline); + } + HydrologyCavePlan plan = new HydrologyCavePlan(source(), HydrologyCaveRejection.NONE, + actions, preconditions, OptionalLong.empty()); + ArrayList visited = new ArrayList<>(); + plan.forEachActionIn(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE + 1, Integer.MIN_VALUE + 1, + (position, action) -> visited.add(position)); + assertEquals(List.of(positions.getFirst()), visited); + visited.clear(); + plan.forEachActionIn(Integer.MAX_VALUE - 1, Integer.MAX_VALUE - 1, Integer.MAX_VALUE, Integer.MAX_VALUE, + (position, action) -> visited.add(position)); + assertEquals(List.of(positions.get(1)), visited); + visited.clear(); + plan.forEachActionIn(-16, -16, 32, 32, (position, action) -> visited.add(position)); + List expectedOrder = List.of(positions.get(4), positions.get(3), positions.get(5), positions.get(6), positions.get(2)); + assertEquals(expectedOrder, visited); + visited.clear(); + assertTrue(plan.allPreconditionsIn(-16, -16, 32, 32, (position, precondition) -> { + visited.add(position); + return true; + })); + assertEquals(expectedOrder, visited); + assertFalse(plan.allPreconditionsIn(-16, -16, 32, 32, (position, precondition) -> false)); + assertTrue(plan.intersectsActions(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE + 1, Integer.MIN_VALUE + 1)); + assertFalse(plan.intersectsActions(2, 2, 16, 16)); + } + + @Test + public void retainedSizeIncludesPrimitiveLookupAndSpatialStorage() { + LinkedHashMap actions = new LinkedHashMap<>(); + LinkedHashMap preconditions = new LinkedHashMap<>(); + CaveVoxelPrecondition baseline = new CaveVoxelPrecondition(CaveVoxel.SOLID, false); + for (int y = 0; y < 1024; y++) { + CavePosition position = new CavePosition(0, y, 0); + actions.put(position, HydrologyCaveAction.WET_SOURCE); + preconditions.put(position, baseline); + } + HydrologyCavePlan plan = new HydrologyCavePlan(source(), HydrologyCaveRejection.NONE, + actions, preconditions, OptionalLong.empty()); + long estimated = plan.estimatedRetainedBytes(); + assertTrue(estimated > 13L * preconditions.size()); + assertTrue(estimated < 48L * preconditions.size()); + assertEquals(estimated, plan.estimatedRetainedBytes()); + assertEquals(actions, plan.actions()); + assertEquals(preconditions, plan.baselinePreconditions()); + } + private HydrologyCaveSource source() { return new HydrologyCaveSource( 1L, diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index ee8755c17..1f9eb05f8 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -3,8 +3,18 @@ package art.arcane.iris.generation.hydrology.runtime; import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.studio.generation.BiomeBuffetLayout; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; import org.junit.Test; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -20,8 +30,101 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; public class IrisHydrologyRoutingTerrainSamplerTest { + @Test + public void bankHeightsAtSeaLevelSkipOceanIntentSampling() { + AtomicInteger classifierCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + throw new AssertionError("Scalar bank sampling must not load a terrain basis."); + }, + (int x, int z) -> x == 0 ? 62.5D : 62.49D, + (int x, int z) -> { + classifierCalls.incrementAndGet(); + return true; + }, + 63 + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16) + ); + + assertEquals(63D, sampler.sampleLandHeight(0, 0), 0D); + assertEquals(0, classifierCalls.get()); + assertTrue(Double.isNaN(sampler.sampleLandHeight(1, 0))); + assertEquals(1, classifierCalls.get()); + } + + @Test + public void buffetBankHeightsMatchFullBasisBeforeAndAfterCacheWarmup() throws Exception { + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); + IrisRegion region = new IrisRegion(); + BiomeBuffetLayout buffet = mock(BiomeBuffetLayout.class); + doReturn(new BiomeBuffetLayout.Cell(biome, region)).when(buffet).terrain(anyDouble(), anyDouble()); + ProceduralStream bridge = ProceduralStream.of( + (x, z) -> InferredType.SEA, + Interpolated.of(value -> 0D, value -> InferredType.SEA) + ); + ProceduralStream regions = ProceduralStream.of( + (x, z) -> region, + Interpolated.of(value -> 0D, value -> region) + ); + setComplexField(complex, "biomeBuffet", buffet); + setComplexField(complex, "bridgeStream", bridge); + setComplexField(complex, "regionStream", regions); + Method natural = IrisComplex.class.getDeclaredMethod( + "sampleHydrologyNatural", Engine.class, int.class, int.class, double.class + ); + Method ocean = IrisComplex.class.getDeclaredMethod("sampleNaturalOcean", int.class, int.class); + natural.setAccessible(true); + ocean.setAccessible(true); + + for (boolean warmBasis : new boolean[]{false, true}) { + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double height) -> { + try { + IrisHydrologyNaturalSample sample = (IrisHydrologyNaturalSample) + natural.invoke(complex, null, x, z, height); + return sample.ocean() ? oceanBasis(x, z, height) : basis(x, z, height); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + }, + (int x, int z) -> 60.25D, + (int x, int z) -> { + try { + return (boolean) ocean.invoke(complex, x, z); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + }, + 63 + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64) + ); + if (warmBasis) { + sampler.sampleBasisWithoutSlope(7, 11); + } + assertEquals(60D, sampler.sampleLandHeight(7, 11), 0D); + HydrologyTerrainSample full = sampler.sampleBasisWithoutSlope(7, 11); + assertEquals(false, full.ocean()); + assertEquals(full.naturalHeight(), sampler.sampleLandHeight(7, 11), 0D); + } + } + + private static void setComplexField(IrisComplex complex, String name, Object value) throws Exception { + Field field = IrisComplex.class.getDeclaredField(name); + field.setAccessible(true); + field.set(complex, value); + } + @Test public void guardedGridSamplesOneAlignedBasisAtEachCoordinate() { int[] spacings = new int[]{64, 128, 256}; @@ -137,6 +240,82 @@ public class IrisHydrologyRoutingTerrainSamplerTest { assertEquals(sloped, slopeFree.withSlope(sloped.slope())); } + @Test + public void landHeightProbesMatchFullTerrainWithoutBuildingPolicySamples() { + AtomicInteger basisCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + int seaLevel = 63; + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + basisCalls.incrementAndGet(); + boolean ocean = IrisHydrologyRoutingTerrainSampler.physicalOcean(x % 2 == 0, naturalHeight, seaLevel); + return ocean ? oceanBasis(x, z, naturalHeight) : basis(x, z, naturalHeight); + }, + (int x, int z) -> { + heightCalls.incrementAndGet(); + return seaLevel + x * 0.5D; + }, + (int x, int z) -> x % 2 == 0, + seaLevel + ), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(128)); + for (int x = -20; x <= 20; x++) { + double expected = x % 2 == 0 && StrictMath.round(seaLevel + x * 0.5D) < seaLevel + ? Double.NaN : (int) StrictMath.round(seaLevel + x * 0.5D); + assertEquals(expected, sampler.sampleLandHeight(x, -17), 0D); + assertEquals(expected, sampler.sampleLandHeight(x, -17), 0D); + } + assertEquals(0, basisCalls.get()); + assertEquals(41, heightCalls.get()); + for (int x = -20; x <= 20; x++) { + HydrologyTerrainSample terrain = sampler.sampleBasisWithoutSlope(x, -17); + assertEquals(terrain.ocean() ? Double.NaN : terrain.naturalHeight(), sampler.sampleLandHeight(x, -17), 0D); + } + assertEquals(41, basisCalls.get()); + assertEquals(41, heightCalls.get()); + } + + @Test + public void landHeightProbesPassNonFiniteSamplesThroughTheExistingBasisRecovery() { + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger basisCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + assertTrue(Double.isNaN(naturalHeight)); + basisCalls.incrementAndGet(); + return basis(x, z, 87D); + }, + (int x, int z) -> { + heightCalls.incrementAndGet(); + return Double.NaN; + }, + (int x, int z) -> true, + 63 + ), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16)); + + assertEquals(87D, sampler.sampleLandHeight(-5, 9), 0D); + assertEquals(87D, sampler.sampleLandHeight(-5, 9), 0D); + assertEquals(1, heightCalls.get()); + assertEquals(1, basisCalls.get()); + } + + @Test + public void failedLandHeightProbesDoNotMemoizeANonFiniteHeight() { + AtomicInteger heightCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> heightCalls.incrementAndGet() == 1 ? Double.NaN : 87D, + (int x, int z) -> false, + 63 + ), IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16)); + + assertThrows(IllegalArgumentException.class, () -> sampler.sampleLandHeight(-5, 9)); + assertEquals(87D, sampler.sampleLandHeight(-5, 9), 0D); + assertEquals(2, heightCalls.get()); + } + @Test public void adjacentGridsShareBasesAndProduceIdenticalOverlap() { AtomicInteger calls = new AtomicInteger(); @@ -167,6 +346,36 @@ public class IrisHydrologyRoutingTerrainSamplerTest { } } + @Test + public void sharedGridSamplesMatchAtPaddedEdgesAndNegativeOwnershipSeams() { + IrisHydrologyRoutingTerrainSampler sampler = sampler(4096); + assertTrue(sampler.supportsSharedGridSamples()); + int spacing = 64; + int width = 5; + int primaryMinimum = -128; + HydrologyTerrainSample[] primary = sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest( + primaryMinimum, primaryMinimum, width, spacing)); + for (int shiftX : new int[]{-128, 128}) { + for (int shiftZ : new int[]{-128, 128}) { + int minimumX = primaryMinimum + shiftX; + int minimumZ = primaryMinimum + shiftZ; + HydrologyTerrainSample[] adjacent = sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest( + minimumX, minimumZ, width, spacing)); + int firstX = Math.max(primaryMinimum, minimumX); + int firstZ = Math.max(primaryMinimum, minimumZ); + int lastX = Math.min(primaryMinimum + (width - 1) * spacing, minimumX + (width - 1) * spacing); + int lastZ = Math.min(primaryMinimum + (width - 1) * spacing, minimumZ + (width - 1) * spacing); + for (int z = firstZ; z <= lastZ; z += spacing) { + for (int x = firstX; x <= lastX; x += spacing) { + int primaryIndex = (z - primaryMinimum) / spacing * width + (x - primaryMinimum) / spacing; + int adjacentIndex = (z - minimumZ) / spacing * width + (x - minimumX) / spacing; + assertSampleRawBits(primary[primaryIndex], adjacent[adjacentIndex]); + } + } + } + } + } + @Test public void serialGridKeepsRowMajorProviderCallOrder() { List calls = new ArrayList<>(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java index 18310d7c1..2aec84967 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -303,6 +304,57 @@ public class SurfaceFootprintCompilerTest { } } + @Test + public void preparedWindowsRetainColumnsAdmissionAndExcavationAcrossFallsAndMouths() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 190 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand((x < 100 ? 100 : 90) + Math.abs(z) / 3, 0D, "land"); + SurfaceCourseResult built = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, + CONSTANT_GEOMETRY, SEA_LEVEL).build(7L, 1L, "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(180, 0, 0)), + SurfaceTerminal.TRIBUTARY, 40, 64); + assertNull(built.rejection()); + RiverCourse falling = course(built.segments()); + RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, + points(180, 200, SEA_LEVEL)))); + RiverCourse curved = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79, + List.of(new HydrologyPoint(-100, 79, -50), new HydrologyPoint(0, 79, 30), + new HydrologyPoint(100, 79, -50))))); + for (RiverCourse course : List.of(falling, mouth, curved)) { + SurfaceFootprintCompiler compiler = compiler(sampler); + SurfaceFootprintCompiler.PreparedCourse prepared = compiler.prepare(course); + for (int x = -128; x < 256; x += 32) { + SurfaceBounds bounds = new SurfaceBounds(x, -80, x + 31, 80); + assertEquals(compiler.compile(course, bounds), compiler.compile(prepared, bounds)); + } + assertEquals(compiler.compile(course), compiler.compile(prepared, null)); + } + } + + @Test + public void subsequentPreparedWindowsDoNotResampleDistantCourseGeometry() { + AtomicInteger distantSamples = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 200) { + distantSamples.incrementAndGet(); + } + return HydrologyTerrainSample.openLand(90 + Math.abs(z) / 2, 0D, "land"); + }; + RiverCourse course = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79, + points(0, 400, 79)))); + SurfaceFootprintCompiler compiler = compiler(sampler); + SurfaceFootprintCompiler.PreparedCourse prepared = compiler.prepare(course); + assertEquals(0, distantSamples.get()); + compiler.compile(prepared, new SurfaceBounds(0, -16, 31, 16)); + assertTrue(distantSamples.get() > 0); + distantSamples.set(0); + SurfaceBounds bounds = new SurfaceBounds(32, -16, 63, 16); + SurfaceFootprint reused = compiler.compile(prepared, bounds); + assertEquals(0, distantSamples.get()); + assertEquals(compiler.compile(course, bounds), reused); + assertTrue(distantSamples.get() > 0); + } + private static SurfaceLayerColumn column(SurfaceFootprint footprint, int x, int z) { for (SurfaceLayerColumn column : footprint.columns()) { if (column.x() == x && column.z() == z) { diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/EngineMantleCoverageTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/EngineMantleCoverageTest.java index aa6bd234b..ccf0934f5 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/EngineMantleCoverageTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/EngineMantleCoverageTest.java @@ -22,6 +22,8 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -33,6 +35,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class EngineMantleCoverageTest { @@ -122,7 +126,7 @@ public class EngineMantleCoverageTest { Chunk neighbor = new Chunk(-2, 0); fixture.real().remove(neighbor); AtomicInteger reads = new AtomicInteger(); - when(fixture.mantle().hasFlag(-2, 0, MantleFlag.REAL)).thenAnswer(invocation -> { + when(fixture.mantle().hasLoadedFlag(-2, 0, MantleFlag.REAL)).thenAnswer(invocation -> { if (reads.incrementAndGet() > 1) { fixture.real().add(neighbor); return true; @@ -181,6 +185,95 @@ public class EngineMantleCoverageTest { } } + @Test + public void cleanupDefersUnloadedCoverageWithoutReadingFlags() { + CoverageFixture fixture = fixture(1); + fill(fixture, 2); + when(fixture.mantle().isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + + assertTrue(actual.isEmpty()); + verify(fixture.mantle(), never()).hasLoadedFlag(anyInt(), anyInt(), any()); + verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt()); + + when(fixture.mantle().isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + assertEquals(9, actual.size()); + } + + @Test + public void cleanupSkipsTargetEvictedAfterCoverageWithoutLoadingIt() { + CoverageFixture fixture = fixture(0); + fill(fixture, 0); + doReturn(false).when(fixture.mantle()).withLoadedChunk(eq(0), eq(0), any()); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + + assertTrue(actual.isEmpty()); + assertTrue(fixture.cleaned().isEmpty()); + verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt()); + verify(fixture.mantle(), never()).hasFlag(anyInt(), anyInt(), any()); + } + + @Test + public void cleanupSkipsHaloEvictedAfterResidencyCheck() { + CoverageFixture fixture = fixture(1); + fill(fixture, 2); + when(fixture.mantle().hasLoadedFlag(anyInt(), anyInt(), eq(MantleFlag.REAL))).thenReturn(false); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + + assertTrue(actual.isEmpty()); + verify(fixture.mantle(), never()).withLoadedChunk(anyInt(), anyInt(), any()); + verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt()); + verify(fixture.mantle(), never()).hasFlag(anyInt(), anyInt(), any()); + } + + @Test + @SuppressWarnings("unchecked") + public void busyTargetCanBeCleanedOnRetryAndCallbackRunsOutsideGuard() { + CoverageFixture fixture = fixture(0); + fill(fixture, 0); + AtomicBoolean busy = new AtomicBoolean(true); + AtomicBoolean guarded = new AtomicBoolean(); + MantleChunk chunk = mock(MantleChunk.class); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + if (busy.getAndSet(false)) { + return false; + } + guarded.set(true); + try { + Predicate> action = invocation.getArgument(2); + boolean cleaned = action.test(chunk); + if (cleaned) { + fixture.cleaned().add(new Chunk(0, 0)); + } + return cleaned; + } finally { + guarded.set(false); + } + }).when(fixture.mantle()).withLoadedChunk(eq(0), eq(0), any()); + List actual = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + fixture.engine().cleanupChunksCoveredBy(0, 0, true, (x, z) -> { + assertTrue("Callback must run after releasing region guard", !guarded.get()); + actual.add(new Chunk(x, z)); + }); + } + assertEquals(List.of(new Chunk(0, 0)), actual); + verify(fixture.mantle(), never()).getChunk(anyInt(), anyInt()); + verify(fixture.mantle(), never()).hasFlag(anyInt(), anyInt(), any()); + } + private static List fullScan(CoverageFixture fixture, int x, int z, int radius) { List result = new ArrayList<>(); for (int offsetX = -radius; offsetX <= radius; offsetX++) { @@ -236,7 +329,8 @@ public class EngineMantleCoverageTest { doReturn(mantle).when(engine).getMantle(); doReturn(radius).when(engine).getRadius(); doReturn(0).when(engine).getRealRadius(); - when(mantle.hasFlag(anyInt(), anyInt(), any())).thenAnswer(invocation -> { + when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(mantle.hasLoadedFlag(anyInt(), anyInt(), any())).thenAnswer(invocation -> { Chunk position = new Chunk(invocation.getArgument(0), invocation.getArgument(1)); MantleFlag flag = invocation.getArgument(2); if (flag == MantleFlag.REAL) { @@ -245,9 +339,13 @@ public class EngineMantleCoverageTest { } return cleaned.contains(position); }); - when(mantle.getChunk(anyInt(), anyInt())).thenAnswer(invocation -> { - cleaned.add(new Chunk(invocation.getArgument(0), invocation.getArgument(1))); - return chunk; + when(mantle.withLoadedChunk(anyInt(), anyInt(), any())).thenAnswer(invocation -> { + Predicate> action = invocation.getArgument(2); + boolean result = action.test(chunk); + if (result) { + cleaned.add(new Chunk(invocation.getArgument(0), invocation.getArgument(1))); + } + return result; }); when(chunk.use()).thenReturn(chunk); doAnswer(invocation -> null).when(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java index c9b0ee36e..d26052ff9 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java @@ -120,20 +120,19 @@ public class IrisComplexHydrologyNoiseCacheTest { } @Test(timeout = 10_000L) - public void concurrentFirstDemandsShareOneReservation() throws Exception { + public void concurrentCacheExpansionsShareOneReservation() throws Exception { try (Fixture fixture = new Fixture(1_024); ExecutorService callers = Executors.newFixedThreadPool(2)) { HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); - HydrologyTileCache cache = fixture.planningCache(budget); CountDownLatch entered = new CountDownLatch(2); CountDownLatch release = new CountDownLatch(1); - cache.setTerrainPreparation(() -> { + Runnable prepare = () -> { entered.countDown(); await(release); fixture.complex.expandHydrologyNoiseCaches(budget); - }); - Future first = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0))); - Future second = callers.submit(() -> cache.get(new HydrologyTileKey(1, 0))); + }; + Future first = callers.submit(prepare); + Future second = callers.submit(prepare); assertTrue(entered.await(5L, TimeUnit.SECONDS)); assertEquals(0L, budget.reservedBytes()); release.countDown(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java index 49d622c97..d5dd32cd3 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java @@ -278,13 +278,15 @@ public class IrisEngineEntryHydrologyTest { } @Test - public void exactOriginChunkDemandsAllFourTilesConcurrently() throws Exception { + public void exactOriginChunkDemandsAllFourTilesWithinTheRootBudget() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class); when(settings.routing()).thenReturn(HydrologyPlannerSettings.defaults().routing()); when(settings.publicationRadius()).thenReturn(64); when(planner.settings()).thenReturn(settings); - CountDownLatch started = new CountDownLatch(4); + int admitted = (int) Math.max(1L, Math.min(4L, Math.min(Runtime.getRuntime().availableProcessors(), + Runtime.getRuntime().maxMemory() / (2L * 1024L * 1024L * 1024L)))); + CountDownLatch started = new CountDownLatch(admitted); CountDownLatch release = new CountDownLatch(1); Set keys = ConcurrentHashMap.newKeySet(); when(planner.plan(any())).thenAnswer(invocation -> { @@ -302,12 +304,13 @@ public class IrisEngineEntryHydrologyTest { Future preparation = caller.submit(() -> cache.prepareChunkColumns(0, 0)); try { assertTrue(started.await(5, TimeUnit.SECONDS)); - assertEquals(Set.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(0, -1), - new HydrologyTileKey(-1, 0), new HydrologyTileKey(0, 0)), keys); + assertEquals(admitted, keys.size()); } finally { release.countDown(); } preparation.get(5, TimeUnit.SECONDS); + assertEquals(Set.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(0, -1), + new HydrologyTileKey(-1, 0), new HydrologyTileKey(0, 0)), keys); cache.prepareChunkColumns(0, 0); assertEquals(4, keys.size()); cache.close(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMantleReadTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMantleReadTest.java new file mode 100644 index 000000000..177144b59 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMantleReadTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterBiomeInject; +import art.arcane.volmlib.util.matter.slices.BiomeInjectMatter; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class IrisEngineMantleReadTest { + @Test + public void missingReadsAndRemovalsLeaveSectionEmpty() { + MantleDataAdapter adapter = IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)); + Matter section = new IrisMatter(16, 16, 16); + + for (int y = 0; y < 16; y++) { + assertNull(adapter.get(section, 0, y, 0, MatterBiomeInject.class)); + adapter.remove(section, 0, y, 0, MatterBiomeInject.class); + } + + assertTrue(section.getSliceMap().isEmpty()); + } + + @Test + public void existingSlicesKeepValuesAndCoordinateRemoval() { + MantleDataAdapter adapter = IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)); + Matter section = new IrisMatter(16, 16, 16); + BiomeInjectMatter slice = new BiomeInjectMatter(16, 16, 16); + MatterBiomeInject value = BiomeInjectMatter.get("minecraft:plains"); + section.putSlice(MatterBiomeInject.class, slice); + slice.set(0, 0, 0, value); + slice.set(4, 4, 4, value); + + assertSame(value, adapter.get(section, 4, 4, 4, MatterBiomeInject.class)); + adapter.remove(section, 0, 0, 0, MatterBiomeInject.class); + + assertNull(adapter.get(section, 0, 0, 0, MatterBiomeInject.class)); + assertSame(value, adapter.get(section, 4, 4, 4, MatterBiomeInject.class)); + assertEquals(1, section.getSliceMap().size()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/ContentKeyValidatorTest.java b/core/src/test/java/art/arcane/iris/pack/ContentKeyValidatorTest.java index e3d2b28c9..886f9b800 100644 --- a/core/src/test/java/art/arcane/iris/pack/ContentKeyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ContentKeyValidatorTest.java @@ -241,6 +241,11 @@ public class ContentKeyValidatorTest { private record FakeRegistries(List blocks, List items, List entities, Map> properties) implements PlatformRegistries { + @Override + public NativeBlockState decodeBlockState(String key) { + return blockOrNull(key); + } + @Override public NativeBlockState block(String key) { return null; diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index fb3978534..23ad36577 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -209,6 +209,11 @@ public class SchemaBuilderIdentityTest { private static final List LOG_PROPERTIES = List.of( new NativeBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null)); + @Override + public NativeBlockState decodeBlockState(String key) { + return blockOrNull(key); + } + @Override public NativeBlockState block(String key) { return null; diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java index b81cef764..00f9986a5 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java @@ -459,6 +459,11 @@ public class SchemaBuilderParityTest { } private static final class FakeRegistries implements PlatformRegistries { + @Override + public NativeBlockState decodeBlockState(String key) { + return blockOrNull(key); + } + @Override public NativeBlockState block(String key) { return null; diff --git a/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java b/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java index e44438866..8e1f3117d 100644 --- a/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java +++ b/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java @@ -80,6 +80,11 @@ final class CompatFixtures { final Set potionEffects = new HashSet<>(List.of("minecraft:speed", "minecraft:strength")); int blockLookups = 0; + @Override + public NativeBlockState decodeBlockState(String key) { + return blockOrNull(key); + } + @Override public NativeBlockState block(String key) { NativeBlockState state = blockOrNull(key, false); diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPersistedBlockStateTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPersistedBlockStateTest.java new file mode 100644 index 000000000..8b01a3f29 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPersistedBlockStateTest.java @@ -0,0 +1,159 @@ +package art.arcane.iris.platform.bukkit; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.block.IrisCustomData; +import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.platform.registry.RegistryUtil; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.PreObjectMatterCell; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Leaves; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class BukkitPersistedBlockStateTest { + @BeforeClass + public static void installServer() { + BukkitTestServer.install(); + try (MockedStatic registry = mockStatic(RegistryUtil.class)) { + registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass")) + .thenReturn(Material.SHORT_GRASS); + BukkitBlockResolution.getAir(); + } + } + + @Test + public void decodingPreservesExplicitPropertiesDespiteGenerationLeafPolicy() { + BukkitRegistries registries = new BukkitRegistries(); + IrisSettings configuration = new IrisSettings(); + configuration.getGenerator().setPreventLeafDecay(true); + String key = "minecraft:oak_leaves[distance=2,persistent=false,waterlogged=true]"; + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + MockedStatic settings = mockStatic(IrisSettings.class)) { + bukkit.when(() -> Bukkit.createBlockData(anyString())) + .thenAnswer(invocation -> parsed(invocation.getArgument(0))); + settings.when(IrisSettings::get).thenReturn(configuration); + + assertEquals(key.replace("persistent=false", "persistent=true"), registries.blockOrNull(key).key()); + NativeBlockState decoded = registries.decodeBlockState(key); + assertEquals(key, decoded.key()); + assertSame(decoded, registries.decodeBlockState(key)); + assertEquals(key, ((BlockData) decoded.nativeHandle()).getAsString()); + } + } + + @Test + public void bothMatterSlicesRoundTripFullNativeProperties() throws IOException { + BukkitRegistries registries = new BukkitRegistries(); + IrisPlatform previous = IrisPlatforms.getOrNull(); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.createBlockData(anyString())) + .thenAnswer(invocation -> parsed(invocation.getArgument(0))); + IrisMatterSupport.ensureRegistered(); + Matter matter = new IrisMatter(16, 16, 16); + String[] keys = { + "minecraft:oak_leaves[distance=3,persistent=false,waterlogged=false]", + "minecraft:oak_leaves[distance=6,persistent=true,waterlogged=true]", + "minecraft:oak_stairs[facing=east,half=top,shape=inner_left,waterlogged=true]" + }; + for (int i = 0; i < keys.length; i++) { + NativeBlockState block = BukkitBlockState.of(parsed(keys[i])); + matter.slice(NativeBlockState.class).set(i, 2, 3, block); + matter.slice(PreObjectMatterCell.class) + .set(i, 2, 3, PreObjectMatterCell.block(block)); + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.write(bytes); + Matter restored = Matter.read(new ByteArrayInputStream(bytes.toByteArray())); + for (int i = 0; i < keys.length; i++) { + assertEquals(keys[i], restored.getSlice(NativeBlockState.class).get(i, 2, 3).key()); + assertEquals(keys[i], restored.getSlice(PreObjectMatterCell.class) + .get(i, 2, 3).block().key()); + } + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + @Test + public void decodingRetainsCustomProviderIdentityAndRegisteredStates() { + BukkitRegistries registries = new BukkitRegistries(); + String key = "test:persisted_leaves[distance=2,persistent=false]"; + BlockData carrier = parsed("minecraft:oak_leaves[distance=2,persistent=false,waterlogged=false]"); + IrisCustomData custom = IrisCustomData.of(carrier, Identifier.fromString(key)); + ExternalDataSVC provider = mock(ExternalDataSVC.class); + when(provider.getBlockData(Identifier.fromString(key))).thenReturn(Optional.of(custom)); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider); + NativeBlockState decoded = registries.decodeBlockState(key); + assertEquals(key, decoded.key()); + assertEquals(key, decoded.deferredPlacementKey()); + assertSame(custom, decoded.nativeHandle()); + } + BukkitBlockResolution.registerCustomBlockData("test", "registered_persisted_leaves", carrier); + assertSame(carrier, registries.decodeBlockState("test:registered_persisted_leaves").nativeHandle()); + } + + @Test + public void invalidPersistedPropertiesAreNotSilentlyStripped() { + String key = "minecraft:oak_leaves[distance=invalid,persistent=false]"; + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.createBlockData(key)).thenThrow(new IllegalArgumentException("invalid distance")); + assertThrows(IllegalArgumentException.class, () -> new BukkitRegistries().decodeBlockState(key)); + } + } + + private static BlockData parsed(String key) { + if (!key.startsWith("minecraft:oak_leaves[")) { + return BukkitTestServer.blockData(key); + } + Leaves leaves = mock(Leaves.class); + AtomicBoolean persistent = new AtomicBoolean(key.contains("persistent=true")); + doAnswer(invocation -> key.replaceFirst("persistent=(true|false)", "persistent=" + persistent.get())) + .when(leaves).getAsString(); + doAnswer(invocation -> key.replaceFirst("persistent=(true|false)", "persistent=" + persistent.get())) + .when(leaves).getAsString(anyBoolean()); + doAnswer(invocation -> { + persistent.set(invocation.getArgument(0)); + return null; + }).when(leaves).setPersistent(anyBoolean()); + doReturn(Material.OAK_LEAVES).when(leaves).getMaterial(); + return leaves; + } +} diff --git a/core/src/test/java/art/arcane/iris/testsupport/CompatTestPlatform.java b/core/src/test/java/art/arcane/iris/testsupport/CompatTestPlatform.java index a12d2cea2..904c340b4 100644 --- a/core/src/test/java/art/arcane/iris/testsupport/CompatTestPlatform.java +++ b/core/src/test/java/art/arcane/iris/testsupport/CompatTestPlatform.java @@ -154,6 +154,11 @@ public final class CompatTestPlatform { return false; } + @Override + public NativeBlockState decodeBlockState(String key) { + return blockOrNull(key); + } + @Override public NativeBlockState block(String key) { NativeBlockState state = blockOrNull(key, false); diff --git a/core/src/test/java/art/arcane/iris/world/pregen/MantleHeapPressureTest.java b/core/src/test/java/art/arcane/iris/world/pregen/MantleHeapPressureTest.java index dd67252ac..6ecb7c50a 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/MantleHeapPressureTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/MantleHeapPressureTest.java @@ -25,6 +25,11 @@ import javax.management.MBeanServer; import javax.management.ObjectName; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -40,15 +45,147 @@ import static org.mockito.Mockito.when; public class MantleHeapPressureTest { private static final MantleHeapPressure.PanicGcPolicy POLICY = - new MantleHeapPressure.PanicGcPolicy(10_000L, 60_000L, 60_000L, 240_000L); + new MantleHeapPressure.PanicGcPolicy(250L, 5_000L, 10_000L, 60_000L, 60_000L, 240_000L); + + @Test + public void engagedAdmissionStartsReclaimAfterUsageDropsBeforeFirstRequest() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, 0.82D, 60_000L, fixture.clock::get, fixture.reclaimer::resetEpisode); + + assertEquals(true, gate.update(0.95D)); + fixture.clock.set(100L); + double fraction = 0.85D; + fixture.reclaimer.request(fraction, gate.update(fraction)); + fixture.clock.set(349L); + fixture.reclaimer.request(fraction, gate.update(fraction)); + assertEquals(0, fixture.explicitCalls.get()); + fixture.clock.set(350L); + fixture.reclaimer.request(fraction, gate.update(fraction)); + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(0, fixture.diagnosticCalls.get()); + + fixture.clock.set(10_349L); + fixture.reclaimer.request(fraction, gate.update(fraction)); + assertEquals(0, fixture.diagnosticCalls.get()); + fixture.clock.set(10_350L); + fixture.reclaimer.request(fraction, gate.update(fraction)); + assertEquals(1, fixture.diagnosticCalls.get()); + } + + @Test + public void ungatedUsageBelowHighWaterDoesNotStartReclaim() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, 0.82D, 60_000L, fixture.clock::get, fixture.reclaimer::resetEpisode); + double fraction = 0.85D; + + fixture.reclaimer.request(fraction, gate.update(fraction)); + fixture.clock.set(120_000L); + fixture.reclaimer.request(fraction, gate.update(fraction)); + + assertEquals(0, fixture.explicitCalls.get()); + assertEquals(0, fixture.diagnosticCalls.get()); + } + + @Test + public void recoveredSpikeCancelsPendingExplicitGc() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, 0.82D, 60_000L, fixture.clock::get, fixture.reclaimer::resetEpisode); + + assertEquals(true, gate.update(0.95D)); + fixture.reclaimer.request(0.95D, false); + fixture.clock.set(100L); + assertEquals(false, gate.update(0.81D)); + fixture.clock.set(250L); + fixture.reclaimer.request(0.90D, false); + assertEquals(0, fixture.explicitCalls.get()); + assertEquals(0, fixture.diagnosticCalls.get()); + + fixture.reclaimer.request(0.95D, false); + fixture.clock.set(300L); + fixture.reclaimer.request(0.82D, false); + fixture.clock.set(500L); + fixture.reclaimer.request(0.90D, false); + assertEquals(0, fixture.explicitCalls.get()); + } + + @Test + public void explicitGcWaitsForGraceWhileHysteresisPressureStillReclaims() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + + fixture.reclaimer.request(0.95D, false); + fixture.clock.set(249L); + fixture.reclaimer.request(0.95D, false); + assertEquals(0, fixture.explicitCalls.get()); + fixture.clock.set(250L); + fixture.reclaimer.request(0.85D, false); + fixture.reclaimer.request(0.85D, false); + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(0, fixture.diagnosticCalls.get()); + } + + @Test + public void recoveredEpisodesShareExplicitCooldownAndEscalateFromActualAttempt() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + fixture.beginExplicit(0.95D); + fixture.reclaimer.resetEpisode(); + fixture.clock.set(1_000L); + fixture.reclaimer.request(0.95D, false); + fixture.clock.set(1_250L); + fixture.reclaimer.request(0.95D, false); + fixture.clock.set(5_249L); + fixture.reclaimer.request(0.95D, false); + assertEquals(1, fixture.explicitCalls.get()); + fixture.clock.set(5_250L); + fixture.reclaimer.request(0.95D, false); + assertEquals(2, fixture.explicitCalls.get()); + fixture.clock.set(15_249L); + fixture.reclaimer.request(0.95D, false); + assertEquals(0, fixture.diagnosticCalls.get()); + fixture.clock.set(15_250L); + fixture.reclaimer.request(0.95D, false); + assertEquals(1, fixture.diagnosticCalls.get()); + } + + @Test(timeout = 10_000L) + public void concurrentRequestersShareOneExplicitAttempt() throws Exception { + ReclaimerFixture fixture = new ReclaimerFixture(0); + fixture.reclaimer.request(0.95D, false); + fixture.clock.set(250L); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + List> requests = new ArrayList<>(); + for (int index = 0; index < 2; index++) { + requests.add(executor.submit(() -> { + ready.countDown(); + start.await(); + fixture.reclaimer.request(0.95D, false); + return null; + })); + } + try { + assertEquals(true, ready.await(5L, TimeUnit.SECONDS)); + } finally { + start.countDown(); + } + for (Future request : requests) { + request.get(5L, TimeUnit.SECONDS); + } + } + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(0, fixture.diagnosticCalls.get()); + } @Test public void normalPressureNeverRequestsEitherGcPath() { ReclaimerFixture fixture = new ReclaimerFixture(0); - fixture.reclaimer.request(0.91D); + fixture.reclaimer.request(0.91D, false); fixture.clock.addAndGet(120_000L); - fixture.reclaimer.request(0.91D); + fixture.reclaimer.request(0.91D, false); assertEquals(0, fixture.explicitCalls.get()); assertEquals(0, fixture.diagnosticCalls.get()); @@ -58,13 +195,13 @@ public class MantleHeapPressureTest { public void sustainedPanicInvokesDiagnosticOnlyAfterNormalReclaimGrace() { ReclaimerFixture fixture = new ReclaimerFixture(0); - fixture.reclaimer.request(0.97D); - fixture.clock.set(9_999L); - fixture.reclaimer.request(0.97D); - fixture.clock.set(10_000L); - fixture.reclaimer.request(0.97D); - fixture.clock.set(69_999L); - fixture.reclaimer.request(0.99D); + fixture.beginExplicit(0.97D); + fixture.clock.set(10_249L); + fixture.reclaimer.request(0.97D, false); + fixture.clock.set(10_250L); + fixture.reclaimer.request(0.97D, false); + fixture.clock.set(70_249L); + fixture.reclaimer.request(0.99D, false); assertEquals(1, fixture.explicitCalls.get()); assertEquals(1, fixture.diagnosticCalls.get()); @@ -75,16 +212,16 @@ public class MantleHeapPressureTest { public void recoveredEpisodeResetsButDiagnosticCooldownStillApplies() { ReclaimerFixture fixture = new ReclaimerFixture(0); - fixture.reclaimer.request(0.97D); - fixture.clock.set(10_000L); - fixture.reclaimer.request(0.97D); + fixture.beginExplicit(0.97D); + fixture.clock.set(10_250L); + fixture.reclaimer.request(0.97D, false); fixture.reclaimer.resetEpisode(); fixture.clock.set(20_000L); - fixture.reclaimer.request(0.97D); - fixture.clock.set(30_000L); - fixture.reclaimer.request(0.97D); - fixture.clock.set(70_000L); - fixture.reclaimer.request(0.97D); + fixture.beginExplicit(0.97D); + fixture.clock.set(30_250L); + fixture.reclaimer.request(0.97D, false); + fixture.clock.set(70_250L); + fixture.reclaimer.request(0.97D, false); assertEquals(2, fixture.explicitCalls.get()); assertEquals(2, fixture.diagnosticCalls.get()); @@ -94,11 +231,11 @@ public class MantleHeapPressureTest { public void highWaterEpisodeDiagnosesAfterNormalReclaimLeavesHeapBelowHighWater() { ReclaimerFixture fixture = new ReclaimerFixture(0); - fixture.reclaimer.request(0.93D); - fixture.clock.set(9_999L); - fixture.reclaimer.request(0.91D); - fixture.clock.set(10_000L); - fixture.reclaimer.request(0.91D); + fixture.beginExplicit(0.93D); + fixture.clock.set(10_249L); + fixture.reclaimer.request(0.91D, false); + fixture.clock.set(10_250L); + fixture.reclaimer.request(0.91D, false); assertEquals(1, fixture.explicitCalls.get()); assertEquals(1, fixture.diagnosticCalls.get()); @@ -109,13 +246,13 @@ public class MantleHeapPressureTest { public void successfulDiagnosticRetriesAfterCooldownWhenPressureRemainsHigh() { ReclaimerFixture fixture = new ReclaimerFixture(0); - fixture.reclaimer.request(0.97D); - fixture.clock.set(10_000L); - fixture.reclaimer.request(0.91D); - fixture.clock.set(69_999L); - fixture.reclaimer.request(0.91D); - fixture.clock.set(70_000L); - fixture.reclaimer.request(0.91D); + fixture.beginExplicit(0.97D); + fixture.clock.set(10_250L); + fixture.reclaimer.request(0.91D, false); + fixture.clock.set(70_249L); + fixture.reclaimer.request(0.91D, false); + fixture.clock.set(70_250L); + fixture.reclaimer.request(0.91D, false); assertEquals(1, fixture.explicitCalls.get()); assertEquals(2, fixture.diagnosticCalls.get()); @@ -126,13 +263,13 @@ public class MantleHeapPressureTest { public void failedDiagnosticRetriesAfterBackoffWithinSameEpisode() { ReclaimerFixture fixture = new ReclaimerFixture(1); - fixture.reclaimer.request(0.97D); - fixture.clock.set(10_000L); - fixture.reclaimer.request(0.91D); - fixture.clock.set(69_999L); - fixture.reclaimer.request(0.91D); - fixture.clock.set(70_000L); - fixture.reclaimer.request(0.91D); + fixture.beginExplicit(0.97D); + fixture.clock.set(10_250L); + fixture.reclaimer.request(0.91D, false); + fixture.clock.set(70_249L); + fixture.reclaimer.request(0.91D, false); + fixture.clock.set(70_250L); + fixture.reclaimer.request(0.91D, false); assertEquals(1, fixture.explicitCalls.get()); assertEquals(2, fixture.diagnosticCalls.get()); @@ -151,6 +288,7 @@ public class MantleHeapPressureTest { releases::incrementAndGet); assertEquals(true, gate.update(0.93D)); + clock.set(30_000L); assertEquals(true, gate.update(0.89D)); clock.set(60_999L); assertEquals(true, gate.update(0.89D)); @@ -161,7 +299,7 @@ public class MantleHeapPressureTest { } @Test - public void renewedHighPressureRestartsHysteresisAndCanReengageAfterRelease() { + public void renewedHighPressureKeepsEpisodeDeadlineAndCanReengageAfterRelease() { AtomicLong clock = new AtomicLong(5_000L); AtomicInteger releases = new AtomicInteger(); MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( @@ -173,15 +311,64 @@ public class MantleHeapPressureTest { assertEquals(true, gate.update(0.93D)); assertEquals(true, gate.update(0.88D)); + clock.set(35_000L); + assertEquals(true, gate.update(0.94D)); + assertEquals(true, gate.update(0.88D)); clock.set(64_999L); assertEquals(true, gate.update(0.93D)); assertEquals(true, gate.update(0.88D)); - clock.set(124_998L); - assertEquals(true, gate.update(0.88D)); - clock.set(124_999L); + clock.set(65_000L); + assertEquals(true, gate.update(0.92D)); + assertEquals(0, releases.get()); assertEquals(false, gate.update(0.88D)); assertEquals(1, releases.get()); + + clock.set(70_000L); assertEquals(true, gate.update(0.92D)); + clock.set(129_999L); + assertEquals(true, gate.update(0.88D)); + clock.set(130_000L); + assertEquals(false, gate.update(0.88D)); + assertEquals(2, releases.get()); + } + + @Test + public void prolongedHighPressureWaitsForSubHighSampleAfterEpisodeDeadline() { + AtomicLong clock = new AtomicLong(5_000L); + AtomicInteger releases = new AtomicInteger(); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, + 0.82D, + 60_000L, + clock::get, + releases::incrementAndGet); + + assertEquals(true, gate.update(0.95D)); + clock.set(65_000L); + assertEquals(true, gate.update(0.92D)); + clock.set(125_000L); + assertEquals(true, gate.update(0.97D)); + assertEquals(0, releases.get()); + assertEquals(false, gate.update(0.91D)); + assertEquals(1, releases.get()); + } + + @Test + public void zeroHysteresisStillBlocksHighWaterSamples() { + AtomicLong clock = new AtomicLong(); + AtomicInteger releases = new AtomicInteger(); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, + 0.82D, + 0L, + clock::get, + releases::incrementAndGet); + + assertEquals(true, gate.update(0.92D)); + assertEquals(true, gate.update(0.95D)); + assertEquals(0, releases.get()); + assertEquals(false, gate.update(0.919D)); + assertEquals(1, releases.get()); } @Test @@ -236,6 +423,12 @@ public class MantleHeapPressureTest { private final List failures = new ArrayList<>(); private final MantleHeapPressure.PanicGcReclaimer reclaimer; + private void beginExplicit(double fraction) { + reclaimer.request(fraction, false); + clock.addAndGet(POLICY.explicitGraceMs()); + reclaimer.request(fraction, false); + } + private ReclaimerFixture(int failuresBeforeSuccess) { MantleHeapPressure.PanicGcActions actions = new MantleHeapPressure.PanicGcActions( clock::get, diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenMantleBackpressureTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenMantleBackpressureTest.java index 48610b1b6..49954628b 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/PregenMantleBackpressureTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenMantleBackpressureTest.java @@ -5,6 +5,9 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle; import org.junit.Test; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; @@ -51,7 +54,7 @@ public class PregenMantleBackpressureTest { backpressure(() -> mantle, 4, 1_000L, () -> { }).enforceMantleBudget(); - verify(mantle, never()).trim(0L, 0); + verify(mantle, never()).trim(0L); verify(mantle, never()).unloadTectonicPlate(0); } @@ -63,7 +66,7 @@ public class PregenMantleBackpressureTest { backpressure(() -> mantle, 4, 1_000L, () -> { }).enforceMantleBudget(); - verify(mantle, times(1)).trim(0L, 0); + verify(mantle, times(1)).trim(0L); verify(mantle, times(1)).unloadTectonicPlate(0); } @@ -71,7 +74,7 @@ public class PregenMantleBackpressureTest { public void aTrimFailureEndsTheWaitInsteadOfSpinning() { Mantle mantle = mock(Mantle.class); when(mantle.getLoadedRegionCount()).thenReturn(20); - doThrow(new IllegalStateException("mantle is gone")).when(mantle).trim(0L, 0); + doThrow(new IllegalStateException("mantle is gone")).when(mantle).trim(0L); AtomicInteger timeouts = new AtomicInteger(); backpressure(() -> mantle, 4, 1_000L, timeouts::incrementAndGet).enforceMantleBudget(); @@ -101,7 +104,7 @@ public class PregenMantleBackpressureTest { backpressure.enforceMantleBudget(); - verify(mantle, never()).trim(0L, 0); + verify(mantle, never()).trim(0L); verify(mantle, never()).unloadTectonicPlate(0); assertEquals(0, timeouts.get()); } @@ -140,6 +143,95 @@ public class PregenMantleBackpressureTest { assertFalse(waiter.isAlive()); } + @Test + public void heapPressurePersistsIdlePlatesBeforeGcEvenBelowOrWithoutACountCap() { + for (int cap : new int[]{0, 9, 16, 96}) { + Mantle mantle = mock(Mantle.class); + when(mantle.getLoadedRegionCount()).thenReturn(9); + List actions = new ArrayList<>(); + AtomicBoolean pressured = new AtomicBoolean(true); + AtomicInteger timeouts = new AtomicInteger(); + when(mantle.saveOldestIdleTectonicPlate()).thenAnswer(invocation -> { + actions.add("persist-and-unload"); + return true; + }); + + backpressure(() -> mantle, cap, 1_000L, timeouts::incrementAndGet) + .awaitHeapHeadroom(pressured::get, () -> { + actions.add("gc"); + pressured.set(false); + }); + + assertEquals(List.of("persist-and-unload", "gc"), actions); + assertEquals(0, timeouts.get()); + verify(mantle, never()).trim(0L, 0); + verify(mantle, never()).trim(0L); + verify(mantle, never()).unloadTectonicPlate(0); + } + } + + @Test + public void heapHeadroomDoesNotEvictRecentlyUsedPlatesWithoutPressure() { + Mantle mantle = mock(Mantle.class); + AtomicInteger collections = new AtomicInteger(); + + backpressure(() -> mantle, 16, 1_000L, () -> { + }).awaitHeapHeadroom(() -> false, collections::incrementAndGet); + + verify(mantle, never()).trim(0L); + verify(mantle, never()).unloadTectonicPlate(0); + verify(mantle, never()).saveOldestIdleTectonicPlate(); + assertEquals(0, collections.get()); + } + + @Test + public void pinnedPlatesStillRespectThePressureWaitDeadline() { + Mantle mantle = mock(Mantle.class); + when(mantle.getLoadedRegionCount()).thenReturn(9); + when(mantle.saveOldestIdleTectonicPlate()).thenReturn(false); + AtomicInteger collections = new AtomicInteger(); + AtomicInteger timeouts = new AtomicInteger(); + + backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet) + .awaitHeapHeadroom(() -> true, collections::incrementAndGet); + + verify(mantle, times(1)).saveOldestIdleTectonicPlate(); + verify(mantle, never()).unloadTectonicPlate(0); + assertEquals(1, collections.get()); + assertEquals(1, timeouts.get()); + } + + @Test + public void failedPressureEvictionStillAttemptsGcAndRespectsTheDeadline() { + Mantle mantle = mock(Mantle.class); + doThrow(new IllegalStateException("plate write failed")).when(mantle).saveOldestIdleTectonicPlate(); + AtomicInteger collections = new AtomicInteger(); + AtomicInteger timeouts = new AtomicInteger(); + + backpressure(() -> mantle, 16, 0L, timeouts::incrementAndGet) + .awaitHeapHeadroom(() -> true, collections::incrementAndGet); + + assertEquals(1, collections.get()); + assertEquals(1, timeouts.get()); + } + + @Test + public void cancelledHeapPressureDoesNotEvictOrCollect() { + Mantle mantle = mock(Mantle.class); + AtomicInteger collections = new AtomicInteger(); + AtomicInteger timeouts = new AtomicInteger(); + PregenMantleBackpressure backpressure = new PregenMantleBackpressure( + () -> mantle, 16, 5, 0L, timeouts::incrementAndGet, () -> "cancelled", () -> true); + + backpressure.awaitHeapHeadroom(() -> true, collections::incrementAndGet); + + verify(mantle, never()).trim(0L); + verify(mantle, never()).unloadTectonicPlate(0); + verify(mantle, never()).saveOldestIdleTectonicPlate(); + assertEquals(0, collections.get()); + assertEquals(0, timeouts.get()); + } + private static PregenMantleBackpressure backpressure( Supplier mantleSupplier, int maxResidentTectonicPlates, diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenWaitPolicyContractTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenWaitPolicyContractTest.java index eccfb7330..cad4d6522 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/PregenWaitPolicyContractTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenWaitPolicyContractTest.java @@ -36,7 +36,10 @@ public class PregenWaitPolicyContractTest { assertTrue(backpressure.contains("progressed.await(waitMs, TimeUnit.MILLISECONDS)")); assertTrue(method(backpressure, "public void enforceMantleBudget()").contains("awaitProgress()")); - assertTrue(method(backpressure, "public void awaitHeapHeadroom()").contains("awaitProgress()")); + assertTrue(method(backpressure, "public void awaitHeapHeadroom()") + .contains("awaitHeapHeadroom(MantleHeapPressure::overHighWater, MantleHeapPressure::requestPanicReclaim)")); + assertTrue(method(backpressure, "void awaitHeapHeadroom(BooleanSupplier heapPressure, Runnable panicReclaim)") + .contains("awaitProgress()")); } @Test diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 7ddba95bf..9234a85d9 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -617,6 +617,11 @@ public final class StubPlatform implements IrisPlatform { } private static final class StubRegistries implements PlatformRegistries { + @Override + public NativeBlockState decodeBlockState(String key) { + return blockOrNull(key); + } + @Override public NativeBlockState block(String key) { return StubBlockState.of(key); diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java index 08a0d49dc..dc6b3ef00 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java @@ -47,6 +47,8 @@ public interface PlatformRegistries { ); } + NativeBlockState decodeBlockState(String key); + /** * Resolves a block key through the platform's compatibility layer, which rewrites keys that moved between * Minecraft versions and consults registered custom-content providers. An unresolvable key is reported and