This commit is contained in:
Brian Neumann-Fopiano
2026-09-21 10:04:22 -04:00
parent 8e3ef1b651
commit 197d2c2461
73 changed files with 4586 additions and 424 deletions
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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() }
}
@@ -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/)
}))
@@ -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}`)
}
@@ -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);
@@ -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]");
@@ -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;
}
}
@@ -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<HydrologyDiagnosticCandidate> 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<HydrologyPoint> 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<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets,
List<RiverCourse> courses, List<HydrologyCavePlan> 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);
}
}
@@ -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<OutletCandidate> 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<Callable<FallbackTrial>> 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<FallbackTrial> 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<SourceCompilationKey, SourceCompilation> 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,
@@ -391,7 +391,7 @@ final class HydrologyFootprintCompiler {
}
RiverFootprint build(Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> columns) {
LinkedHashMap<Long, HydrologyColumnSample> immutable = new LinkedHashMap<>();
Long2ObjectOpenHashMap<HydrologyColumnSample> immutable = new Long2ObjectOpenHashMap<>(columns.size());
for (Long2ObjectMap.Entry<FootprintMutableColumn> entry : columns.long2ObjectEntrySet()) {
immutable.put(entry.getLongKey(), entry.getValue().build());
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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<HydrologyPoint> 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<CrossTileResolvedOwner> 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<HydrologyTerrainSample> basis;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basisWithoutSlope;
private final Long2DoubleOpenHashMap landHeights;
private HydrologyLandHeightCache sharedLandHeights;
final Long2ObjectOpenHashMap<HydrologyTerrainSample> detailed;
private final Long2ObjectOpenHashMap<HydrologyRoutingTerrainSampler.NaturalClassification> classifications;
private final HashMap<HydrologyGeometrySampler.Request, Integer> 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;
@@ -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.");
}
}
}
}
@@ -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<OutletCandidate> 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();
}
@@ -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<SourceTrial> completed = HydrologyForkJoin.invokeAll(tasks,
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
ArrayList<SourceTrial> 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;
@@ -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<Edge, Reach> edges = Caffeine.newBuilder().maximumSize(128).build();
private final Cache<Edge, Reach> edges;
private final ConcurrentHashMap<Edge, HydrologyForkJoin.Task<Reach>> loading = new ConcurrentHashMap<>();
private final Cache<Long, Terrain> terrain = Caffeine.newBuilder().maximumSize(Math.max(MAXIMUM_SAMPLES,
Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))).build();
private final Cache<Long, Terrain> 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) {
@@ -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<RouteCandidate> firstLayer = layers.getFirst();
List<RouteCandidate> 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<RouteCandidate> beforeLayer = layers.get(layerIndex - 2);
List<RouteCandidate> previousLayer = layers.get(layerIndex - 1);
List<RouteCandidate> 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<? extends 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]);
@@ -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<HydrologyPoint> smoothTerrainRoute(List<HydrologyPoint> route) {
@@ -220,12 +220,12 @@ final class HydrologyRoutePath {
boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) {
List<HydrologyPoint> 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;
}
}
@@ -5,6 +5,10 @@ public interface HydrologyRoutingTerrainSampler {
NaturalClassification classifyNatural(int blockX, int blockZ);
default boolean supportsSharedGridSamples() {
return false;
}
enum NaturalClassification {
LAND,
OCEAN,
@@ -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<HydrologyTileKey, SourceRoutingContext> 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,
@@ -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),
@@ -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<SharedTileKey, HydrologyTile> 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<HydrologyTileKey> prefetchQueue;
private final Set<HydrologyTileKey> queuedPrefetches;
private final LinkedHashSet<ChunkCoordinate> queriedChunks;
private final Set<CacheLoadKey<HydrologyTileKey>> demandedPlans;
private final AtomicBoolean prefetchActive;
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, CompletableFuture<HydrologyTile>> 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<HydrologyDiagnosticCandidate> 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<HydrologyDiagnosticCandidate> 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<HydrologyDiagnosticCandidate> 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<List<HydrologyDiagnosticCandidate>> diagnoseAsync(CacheLoadKey<HydrologyTileKey> loadKey) {
List<HydrologyDiagnosticCandidate> 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<CompletableFuture<ChunkColumns>> 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<ChunkColumns> 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<ChunkColumns> load : composing.values()) {
collectActiveLoad(active, load);
if (load.owner() == null) {
queued.add(load.future());
} else {
collectActiveLoad(active, load);
}
}
for (PendingLoad<List<HydrologyDiagnosticCandidate>> 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<HydrologyTileKey> keys = relevantKeys(chunkX, chunkZ);
ArrayList<HydrologyTile> 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<ChunkColumns> owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>());
PendingLoad<ChunkColumns> existing;
boolean claimedQuery = false;
synchronized (publicationLock) {
requireOpen();
existing = composing.putIfAbsent(loadKey, owned);
if (existing != null && existing.owner() == null && !existing.future().isDone()) {
PendingLoad<ChunkColumns> 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<HydrologyTileKey> relevantKeys = relevantKeys(chunkX, chunkZ);
return composeChunkColumns(chunkX, chunkZ, relevantKeys, tiles(relevantKeys));
}
private ChunkColumns composeChunkColumnsInline(int chunkX, int chunkZ, long epoch) {
ArrayList<HydrologyTileKey> keys = relevantKeys(chunkX, chunkZ);
ArrayList<HydrologyTile> 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<HydrologyTileKey> relevantKeys,
List<HydrologyTile> relevantTiles) {
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
ArrayList<HydrologyTileKey> 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<HydrologyTile> 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<HydrologyTileKey> loadKey;
CacheLoadKey<HydrologyTileKey> 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<ChunkColumns> composeAsync(ChunkCoordinate chunk, long epoch) {
CacheLoadKey<Long> loadKey = new CacheLoadKey<>(epoch, chunk.packed());
PendingLoad<ChunkColumns> queued = new PendingLoad<>(null, new CompletableFuture<>());
synchronized (publicationLock) {
if (closed.get() || epoch != cacheEpoch.get()) {
return CompletableFuture.failedFuture(new CancellationException("Hydrology query was invalidated."));
}
PendingLoad<ChunkColumns> 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<Long> loadKey, ChunkCoordinate chunk,
PendingLoad<ChunkColumns> queued) {
PendingLoad<ChunkColumns> 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<CompletableFuture<HydrologyTile>> 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<K>(long epoch, K key) {
}
@@ -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<CavePosition, Byte> packed = new LinkedHashMap<>(requestedPreconditions.size());
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : requestedActions.entrySet()) {
CaveVoxelPrecondition precondition = requestedPreconditions.get(entry.getKey());
packed.put(entry.getKey(), encode(entry.getValue(), precondition));
}
for (Map.Entry<CavePosition, CaveVoxelPrecondition> entry : requestedPreconditions.entrySet()) {
packed.putIfAbsent(entry.getKey(), encode(null, entry.getValue()));
}
Map<CavePosition, Byte> 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<CavePosition, HydrologyCaveAction> 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<CavePosition, HydrologyCaveAction> actions,
Map<CavePosition, CaveVoxelPrecondition> 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<CavePosition, HydrologyCaveAction> entry : actions.entrySet()) {
append(entry.getKey(), encode(entry.getValue(), preconditions.get(entry.getKey())));
}
for (Map.Entry<CavePosition, CaveVoxelPrecondition> 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<CavePosition, CaveVoxelPrecondition> 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<CavePosition> {
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<CavePosition, HydrologyCaveAction> {
private final Map<CavePosition, Byte> packed;
private final PackedPositions packed;
private final int size;
private final PositionSpatialIndex spatialIndex;
private ActionMap(Map<CavePosition, Byte> 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<? super CavePosition, ? super HydrologyCaveAction> consumer) {
Objects.requireNonNull(consumer);
for (Map.Entry<CavePosition, Byte> 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<CavePosition, HydrologyCaveAction> 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<CavePosition, CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private final PackedPositions packed;
private final PositionOrder order;
private final PositionSpatialIndex spatialIndex;
private PreconditionMap(
Map<CavePosition, Byte> packed,
List<CavePosition> order,
PackedPositions packed,
PositionOrder order,
PositionSpatialIndex spatialIndex
) {
this.packed = packed;
@@ -306,8 +435,8 @@ public record HydrologyCavePlan(
@Override
public void forEach(BiConsumer<? super CavePosition, ? super CaveVoxelPrecondition> 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<CavePosition, CaveVoxelPrecondition> 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<Long, List<CavePosition>> positionsByChunk;
private final PackedPositions packed;
private final Long2ObjectOpenHashMap<int[]> positionsByChunk;
private PositionSpatialIndex(Map<CavePosition, Byte> packed) {
HashMap<Long, ArrayList<CavePosition>> 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<IntArrayList> 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<Long, List<CavePosition>> immutable = HashMap.newHashMap(mutable.size());
for (Map.Entry<Long, ArrayList<CavePosition>> entry : mutable.entrySet()) {
immutable.put(entry.getKey(), List.copyOf(entry.getValue()));
positionsByChunk = new Long2ObjectOpenHashMap<>(mutable.size());
for (Long2ObjectMap.Entry<IntArrayList> 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<CavePosition> 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<CavePosition> 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<CavePosition> 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<CavePosition> 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<CavePosition> 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<CavePosition> 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<CavePosition> {
private final Map<CavePosition, Byte> packed;
private final PackedPositions packed;
private final int size;
private ActionKeySet(Map<CavePosition, Byte> packed, int size) {
private ActionKeySet(PackedPositions packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<CavePosition> 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<Entry<CavePosition, HydrologyCaveAction>> {
private final Map<CavePosition, Byte> packed;
private final PackedPositions packed;
private final int size;
private ActionEntrySet(Map<CavePosition, Byte> packed, int size) {
private ActionEntrySet(PackedPositions packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<Entry<CavePosition, HydrologyCaveAction>> 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<Entry<CavePosition, CaveVoxelPrecondition>> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private final PackedPositions packed;
private final PositionOrder order;
private PreconditionEntrySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
private PreconditionEntrySet(PackedPositions packed, PositionOrder order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<Entry<CavePosition, CaveVoxelPrecondition>> 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<CavePosition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private final PackedPositions packed;
private final PositionOrder order;
private PreconditionKeySet(Map<CavePosition, Byte> packed, List<CavePosition> 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<HydrologyCaveAction> {
private final Map<CavePosition, Byte> packed;
private final PackedPositions packed;
private final int size;
private ActionValues(Map<CavePosition, Byte> packed, int size) {
private ActionValues(PackedPositions packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<HydrologyCaveAction> 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<CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private final PackedPositions packed;
private final PositionOrder order;
private PreconditionValues(Map<CavePosition, Byte> packed, List<CavePosition> order) {
private PreconditionValues(PackedPositions packed, PositionOrder order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<CaveVoxelPrecondition> 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<T> implements Iterator<T> {
private final Iterator<Map.Entry<CavePosition, Byte>> entries;
private Map.Entry<CavePosition, Byte> next;
protected final PackedPositions packed;
private int next;
private FilteredActionIterator(Iterator<Map.Entry<CavePosition, Byte>> 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<CavePosition, Byte> selected = next;
next = null;
return map(selected);
return map(next++);
}
protected abstract T map(Map.Entry<CavePosition, Byte> entry);
protected abstract T map(int row);
private void advance() {
while (next == null && entries.hasNext()) {
Map.Entry<CavePosition, Byte> 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<CavePosition> {
private ActionKeyIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
private ActionKeyIterator(PackedPositions packed) {
super(packed);
}
@Override
protected CavePosition map(Map.Entry<CavePosition, Byte> entry) {
return entry.getKey();
protected CavePosition map(int row) {
return packed.position(row);
}
}
private static final class ActionEntryIterator
extends FilteredActionIterator<Entry<CavePosition, HydrologyCaveAction>> {
private ActionEntryIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
private ActionEntryIterator(PackedPositions packed) {
super(packed);
}
@Override
protected Entry<CavePosition, HydrologyCaveAction> map(Map.Entry<CavePosition, Byte> entry) {
return Map.entry(entry.getKey(), decodeAction(entry.getValue()));
protected Entry<CavePosition, HydrologyCaveAction> map(int row) {
return Map.entry(packed.position(row), decodeAction(packed.flags[row]));
}
}
private static final class ActionValueIterator extends FilteredActionIterator<HydrologyCaveAction> {
private ActionValueIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
private ActionValueIterator(PackedPositions packed) {
super(packed);
}
@Override
protected HydrologyCaveAction map(Map.Entry<CavePosition, Byte> entry) {
return decodeAction(entry.getValue());
protected HydrologyCaveAction map(int row) {
return decodeAction(packed.flags[row]);
}
}
private static final class PreconditionEntryIterator
implements Iterator<Entry<CavePosition, CaveVoxelPrecondition>> {
private final Map<CavePosition, Byte> packed;
private final Iterator<CavePosition> positions;
private final PackedPositions packed;
private final int[] rows;
private int index;
private PreconditionEntryIterator(
Map<CavePosition, Byte> packed,
Iterator<CavePosition> 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<CavePosition, CaveVoxelPrecondition> 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<CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final Iterator<CavePosition> positions;
private final PackedPositions packed;
private final int[] rows;
private int index;
private PreconditionValueIterator(
Map<CavePosition, Byte> packed,
Iterator<CavePosition> 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++]]);
}
}
}
@@ -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(
@@ -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) {
}
}
@@ -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<SurfaceLayerColumn> 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<HydraulicSegment> 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<HydraulicSegment> 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<SurfaceColumn> 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<HydrologyPoint> 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<HydraulicSegment> 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) {
}
}
@@ -356,7 +356,11 @@ public interface EngineMantle extends MatterGenerator {
}
default boolean cleanupCoveredChunk(int x, int z, boolean force) {
MantleChunk<Matter> chunk = getMantle().getChunk(x, z).use();
return cleanupCoveredChunk(getMantle().getChunk(x, z), force);
}
default boolean cleanupCoveredChunk(MantleChunk<Matter> chunk, boolean force) {
chunk.use();
try {
synchronized (chunk) {
if (chunk.isFlagged(MantleFlag.CLEANED)) {
@@ -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;
}
/**
@@ -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 <T> void remove(Matter section, int x, int y, int z, Class<T> type) {
MatterSlice<T> slice = section.slice(type);
slice.set(x, y, z, null);
MatterSlice<T> slice = section.getSlice(type);
if (slice != null) {
slice.set(x, y, z, null);
}
}
@Override
public <T> T get(Matter section, int x, int y, int z, Class<T> type) {
MatterSlice<T> slice = section.slice(type);
return slice.get(x, y, z);
MatterSlice<T> slice = section.getSlice(type);
return slice == null ? null : slice.get(x, y, z);
}
@Override
@@ -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);
}
@@ -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);
@@ -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);
@@ -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) {
@@ -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) {
@@ -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<NativeBlockState> {
@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;
}
}
@@ -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<PreObjectMatterCell>
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
@@ -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<HydrologyTileKey, HydrologyRegionalNetwork> drafts =
(Cache<HydrologyTileKey, HydrologyRegionalNetwork>) 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<Integer, Integer> 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());
}
}
@@ -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<List<HydrologyDiagnosticCandidate>> first = caller.submit(() -> cache.diagnosticCandidates(key));
assertTrue(started.await(5L, TimeUnit.SECONDS));
cache.clear();
assertEquals(fresh, cache.diagnosticCandidates(key));
Future<List<HydrologyDiagnosticCandidate>> 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());
@@ -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<Long> 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<HydrologyOwnerDraft> 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 {
@@ -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<Future<Double>> 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<Double> 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));
}
}
}
@@ -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();
@@ -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<HydrologyTileKey> 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<CrossTileResolvedOwner> 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<Runnable> 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
@@ -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<HydrologyPlanningAdmission.Permit> 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<HydrologyTile> first = callers.submit(() -> firstCache.get(key));
assertTrue(firstStarted.await(5L, TimeUnit.SECONDS));
Future<HydrologyTile> 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<HydrologyPlanningAdmission.Permit> 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<HydrologyTile> 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<HydrologyPlanningAdmission.Permit> holdRoots(int count) {
ArrayList<HydrologyPlanningAdmission.Permit> held = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
held.add(HydrologyPlanningAdmission.acquireRoot(() -> false));
}
return held;
}
private static void releaseRoots(ArrayList<HydrologyPlanningAdmission.Permit> 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());
}
}
@@ -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<Future<?>> 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<List<Integer>> 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<Integer> 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<Thread> waitingThread = new AtomicReference<>();
CountDownLatch waiting = new CountDownLatch(1);
try (HydrologyPlanningAdmission.Permit held = admission.acquire(() -> false)) {
Future<Boolean> 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);
}
}
}
@@ -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
@@ -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<Integer> 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));
@@ -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) {
}
}
@@ -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));
}
}
}
@@ -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<HydrologyTileKey, SourceRoutingContext> 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<HydrologyTileKey, SourceRoutingContext> 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<HydrologyTileKey, SourceRoutingContext> 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<HydrologyGridNode> 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<Long, Double> weights = new HashMap<>();
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
@@ -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(
@@ -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(
@@ -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<Runnable> 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<Optional<HydrologyColumnSample>> 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<Runnable> 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<Optional<HydrologyColumnSample>> owner = callers.submit(() -> cache.columnAt(40, 40));
assertTrue(planning.await(5L, TimeUnit.SECONDS));
Future<Optional<HydrologyColumnSample>> 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<Runnable> 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<HydrologyTile> 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<HydrologyTileKey> 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<List<HydrologyTile>> 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<HydrologyTile> 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<HydrologyTile> 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<HydrologyTileKey> keys = List.of(
new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0));
Future<List<HydrologyTile>> 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<Runnable> 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)) {
@@ -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<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
actions.put(guard, HydrologyCaveAction.SEAL_GUARD);
actions.put(dry, HydrologyCaveAction.DRY_AIR);
actions.put(wet, HydrologyCaveAction.WET_SOURCE);
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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<CavePosition> persistedOrder = Comparator.comparingInt(CavePosition::x)
.thenComparingInt(CavePosition::z).thenComparingInt(CavePosition::y);
ArrayList<CavePosition> expectedActions = new ArrayList<>(actions.keySet());
expectedActions.sort(persistedOrder);
ArrayList<CavePosition> 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<CavePosition> 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();
@@ -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<CavePosition> 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<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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<CavePosition, HydrologyCaveAction> visitedActions = new LinkedHashMap<>();
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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<CavePosition> 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<CavePosition> 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<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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<CavePosition> 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<CavePosition> 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<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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,
@@ -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<InferredType> bridge = ProceduralStream.of(
(x, z) -> InferredType.SEA,
Interpolated.of(value -> 0D, value -> InferredType.SEA)
);
ProceduralStream<IrisRegion> 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<String> calls = new ArrayList<>();
@@ -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) {
@@ -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<Chunk> 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<Chunk> 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<Chunk> 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<Matter> chunk = mock(MantleChunk.class);
when(chunk.use()).thenReturn(chunk);
doAnswer(invocation -> {
if (busy.getAndSet(false)) {
return false;
}
guarded.set(true);
try {
Predicate<MantleChunk<Matter>> 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<Chunk> 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<Chunk> fullScan(CoverageFixture fixture, int x, int z, int radius) {
List<Chunk> 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<MantleChunk<Matter>> 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());
@@ -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();
@@ -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<HydrologyTileKey> 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();
@@ -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<Matter> 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<Matter> 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());
}
}
@@ -241,6 +241,11 @@ public class ContentKeyValidatorTest {
private record FakeRegistries(List<String> blocks, List<String> items, List<String> entities,
Map<String, List<NativeBlockProperty>> properties) implements PlatformRegistries {
@Override
public NativeBlockState decodeBlockState(String key) {
return blockOrNull(key);
}
@Override
public NativeBlockState block(String key) {
return null;
@@ -209,6 +209,11 @@ public class SchemaBuilderIdentityTest {
private static final List<NativeBlockProperty> 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;
@@ -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;
@@ -80,6 +80,11 @@ final class CompatFixtures {
final Set<String> 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);
@@ -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<RegistryUtil> 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> bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS);
MockedStatic<IrisSettings> 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> 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.<NativeBlockState>slice(NativeBlockState.class).set(i, 2, 3, block);
matter.<PreObjectMatterCell>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.<NativeBlockState>getSlice(NativeBlockState.class).get(i, 2, 3).key());
assertEquals(keys[i], restored.<PreObjectMatterCell>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<IrisServices> 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> 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;
}
}
@@ -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);
@@ -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<Future<?>> 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<Throwable> 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,
@@ -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<String> 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<Mantle> mantleSupplier,
int maxResidentTectonicPlates,
@@ -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
@@ -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);
@@ -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