Object Studio and Plausibilizer 1.0

This commit is contained in:
Brian Neumann-Fopiano
2026-04-18 14:50:36 -04:00
parent e6a8351e57
commit 88bbce82fe
21 changed files with 2468 additions and 12 deletions
@@ -18,7 +18,9 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.ExternalDataPackPipeline;
import art.arcane.iris.core.service.ObjectStudioSaveService;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
@@ -101,6 +103,18 @@ public class CommandFind implements DirectorExecutor {
return;
}
Player studioPlayer = player();
if (studioPlayer != null) {
try {
if (ObjectStudioSaveService.get().teleportTo(studioPlayer, object)) {
sender().sendMessage(C.GREEN + "Object Studio: teleporting to " + object);
return;
}
} catch (Throwable t) {
Iris.reportError(t);
}
}
if (e.hasObjectPlacement(object)) {
e.gotoObject(object, player(), teleport);
return;
@@ -21,16 +21,19 @@ package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.link.WorldEditLink;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.service.ObjectSVC;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.service.WandSVC;
import art.arcane.iris.core.tools.IrisConverter;
import art.arcane.iris.core.tools.TreePlausibilizer;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.*;
import art.arcane.volmlib.util.data.Cuboid;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.iris.util.common.data.registry.Materials;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@@ -222,6 +225,220 @@ public class CommandObject implements DirectorExecutor {
}
}
@Director(description = "Bridge unreachable leaves with hidden logs so trees are vanilla-decay-plausible",
origin = DirectorOrigin.BOTH, studio = false)
public void plausibilize(
@Param(description = "Pack key (trees/bonsai/smbase1), pack prefix (trees/), or filesystem path to a .iob file or directory")
String target,
@Param(description = "Analyze only, do not write", defaultValue = "false")
boolean dryRun,
@Param(description = "Flip persistent=true leaves to false and bridge them as well",
defaultValue = "false")
boolean normalize,
@Param(description = "Wipe scattered leaves and repaint a canopy shell around every log, then bridge any gaps with interior log tendrils",
defaultValue = "false")
boolean smoke,
@Param(description = "Canopy shell radius (smoke mode only), clamped to [0,5]",
defaultValue = "2")
int radius
) {
List<Target> targets = resolveTargets(target);
if (targets.isEmpty()) {
sender().sendMessage(C.RED + "No objects matched: " + target);
return;
}
sender().sendMessage(C.IRIS + "Plausibilize: queued " + targets.size() + " object(s)"
+ (dryRun ? " [DRY RUN]" : "")
+ (normalize ? " [NORMALIZE]" : "")
+ (smoke ? " [SMOKE r=" + radius + "]" : ""));
org.bukkit.command.CommandSender s = sender();
J.a(() -> runPlausibilize(targets, dryRun, normalize, smoke, radius, s));
}
private List<Target> resolveTargets(String target) {
List<Target> out = new ArrayList<>();
if (target == null || target.isEmpty()) {
return out;
}
File direct = new File(target);
if (direct.isFile() && target.toLowerCase().endsWith(".iob")) {
out.add(new Target(direct.getName().replaceAll("\\.iob$", ""), direct));
return out;
}
if (direct.isDirectory()) {
walkIob(direct, direct, out);
return out;
}
IrisData irisData = data();
if (irisData != null) {
ResourceLoader<IrisObject> loader = irisData.getObjectLoader();
if (!target.endsWith("/") && loader.findFile(target) != null) {
out.add(new Target(target, null));
return out;
}
String prefix = target.endsWith("/") ? target : target + "/";
for (String k : loader.getPossibleKeys()) {
if (k.startsWith(prefix)) {
out.add(new Target(k, null));
}
}
return out;
}
File packsFolder = Iris.instance.getDataFolder("packs");
File[] packs = packsFolder.listFiles(File::isDirectory);
if (packs != null) {
for (File pack : packs) {
File objectsRoot = new File(pack, "objects");
if (!objectsRoot.isDirectory()) continue;
File candidate = new File(objectsRoot, target + ".iob");
if (candidate.isFile()) {
out.add(new Target(pack.getName() + "/" + target, candidate));
continue;
}
File candidateDir = new File(objectsRoot, target);
if (candidateDir.isDirectory()) {
walkIob(candidateDir, objectsRoot, out);
}
}
}
return out;
}
private static void walkIob(File root, File keyRoot, List<Target> out) {
File[] kids = root.listFiles();
if (kids == null) return;
for (File f : kids) {
if (f.isDirectory()) {
walkIob(f, keyRoot, out);
} else if (f.getName().toLowerCase().endsWith(".iob")) {
String rel = keyRoot.toPath().relativize(f.toPath()).toString()
.replace(File.separatorChar, '/')
.replaceAll("\\.iob$", "");
out.add(new Target(rel, f));
}
}
}
private record Target(String key, File file) {
}
private static void runPlausibilize(
List<Target> targets,
boolean dryRun,
boolean normalize,
boolean smoke,
int radius,
org.bukkit.command.CommandSender s
) {
int processed = 0;
int skipped = 0;
int failed = 0;
int changed = 0;
long totalLogsAdded = 0L;
long totalReachableBefore = 0L;
long totalLeaves = 0L;
long totalPersistentInput = 0L;
long totalLeavesAdded = 0L;
long totalLeavesRemoved = 0L;
long totalNormalized = 0L;
long totalUnreachableAfter = 0L;
int progressStep = Math.max(1, targets.size() / 20);
int index = 0;
for (Target t : targets) {
index++;
try {
IrisObject o = loadTarget(t);
if (o == null) {
s.sendMessage(C.YELLOW + " skip " + t.key() + ": failed to load");
skipped++;
continue;
}
TreePlausibilizer.Result r = dryRun
? TreePlausibilizer.analyze(o, normalize, smoke, radius)
: TreePlausibilizer.apply(o, normalize, smoke, radius);
if (r.skipReason() != null) {
s.sendMessage(C.YELLOW + " skip " + t.key() + ": " + r.skipReason());
skipped++;
continue;
}
boolean touched = r.logsAdded() > 0 || r.leavesAdded() > 0
|| r.leavesRemoved() > 0 || r.leavesNormalized() > 0;
if (!dryRun && touched) {
File dest = o.getLoadFile() != null ? o.getLoadFile() : t.file();
if (dest != null) {
o.write(dest);
changed++;
}
}
processed++;
totalLogsAdded += r.logsAdded();
totalReachableBefore += r.reachableBefore();
totalLeaves += r.totalLeaves();
totalPersistentInput += r.persistentLeavesInput();
totalLeavesAdded += r.leavesAdded();
totalLeavesRemoved += r.leavesRemoved();
totalNormalized += r.leavesNormalized();
totalUnreachableAfter += r.unreachableAfter();
if (touched || targets.size() == 1) {
s.sendMessage(C.GRAY + " " + t.key()
+ " leaves=" + r.totalLeaves()
+ " persistentIn=" + r.persistentLeavesInput()
+ " reachable=" + r.reachableBefore()
+ " logsAdded=" + r.logsAdded()
+ " leavesAdded=" + r.leavesAdded()
+ " leavesRemoved=" + r.leavesRemoved()
+ " normalized=" + r.leavesNormalized()
+ " remaining=" + r.unreachableAfter());
}
if (targets.size() > 1 && index % progressStep == 0) {
s.sendMessage(C.IRIS + " [" + index + "/" + targets.size() + "]");
}
} catch (Throwable e) {
s.sendMessage(C.RED + " fail " + t.key() + ": " + e.getClass().getSimpleName()
+ ": " + e.getMessage());
e.printStackTrace();
failed++;
}
}
s.sendMessage(C.IRIS + "Done."
+ " processed=" + processed
+ " changed=" + changed
+ " skipped=" + skipped
+ " failed=" + failed
+ " leaves=" + totalLeaves
+ " persistentIn=" + totalPersistentInput
+ " reachableBefore=" + totalReachableBefore
+ " logsAdded=" + totalLogsAdded
+ " leavesAdded=" + totalLeavesAdded
+ " leavesRemoved=" + totalLeavesRemoved
+ " normalized=" + totalNormalized
+ " remaining=" + totalUnreachableAfter);
}
private static IrisObject loadTarget(Target t) throws IOException {
if (t.file() != null) {
IrisObject o = new IrisObject();
o.read(t.file());
o.setLoadFile(t.file());
return o;
}
return IrisData.loadAnyObject(t.key(), null);
}
@Director(description = "Convert .schem files in the 'convert' folder to .iob files.")
public void convert () {
try {
@@ -24,6 +24,8 @@ import art.arcane.iris.core.gui.NoiseExplorerGUI;
import art.arcane.iris.core.gui.VisionGUI;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.project.IrisProject;
import art.arcane.iris.core.runtime.ObjectStudioActivation;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine;
@@ -123,6 +125,101 @@ public class CommandStudio implements DirectorExecutor {
Iris.service(StudioSVC.class).open(sender(), seed, dimension.getLoadKey());
}
@Director(description = "Open an object studio world (grid of every object; dimension optional, defaults to all packs)", aliases = {"obj", "objs"}, sync = true)
public void object(
@Param(defaultValue = "null", description = "Optional dimension whose object pack to lay out; omit to aggregate objects from every pack", aliases = "dim", customHandler = NullableDimensionHandler.class)
IrisDimension dimension,
@Param(defaultValue = "1337", description = "The seed to generate the studio with", aliases = "s")
long seed
) {
VolmitSender commandSender = sender();
java.util.Map<String, IrisData> sources = new java.util.LinkedHashMap<>();
IrisDimension hostDimension = dimension;
if (dimension != null) {
IrisData data = dimension.getLoader();
if (data == null) {
data = IrisData.get(dimension.getLoadFile().getParentFile().getParentFile());
}
sources.put(data.getDataFolder().getName(), data);
} else {
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
File[] packs = workspace == null ? null : workspace.listFiles();
if (packs != null) {
Arrays.sort(packs, java.util.Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER));
for (File pack : packs) {
if (!pack.isDirectory()) continue;
File dimensionsDir = new File(pack, "dimensions");
if (!dimensionsDir.isDirectory()) continue;
IrisData data = IrisData.get(pack);
String[] keys = data.getObjectLoader().getPossibleKeys();
if (keys == null || keys.length == 0) continue;
sources.put(pack.getName(), data);
if (hostDimension == null) {
File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json"));
if (dimFiles != null && dimFiles.length > 0) {
String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
IrisDimension loaded = data.getDimensionLoader().load(loadKey);
if (loaded != null) {
hostDimension = loaded;
}
}
}
}
}
}
if (hostDimension == null || sources.isEmpty()) {
commandSender.sendMessage(C.RED + "No packs with objects were found on this server.");
return;
}
int totalObjects = 0;
for (IrisData d : sources.values()) {
String[] k = d.getObjectLoader().getPossibleKeys();
if (k != null) totalObjects += k.length;
}
if (totalObjects == 0) {
commandSender.sendMessage(C.RED + "No objects to place across the selected pack(s).");
return;
}
hostDimension.setStudioMode(StudioMode.OBJECT_BUFFET);
ObjectStudioActivation.activate(hostDimension.getLoadKey());
ObjectStudioActivation.setSources(hostDimension.getLoadKey(), sources);
String scope = dimension == null
? ("all packs [" + sources.size() + "]")
: ("\"" + hostDimension.getName() + "\"");
commandSender.sendMessage(C.GREEN + "Opening Object Studio for " + scope + " ("
+ totalObjects + " objects)");
IrisDimension finalHost = hostDimension;
try {
Iris.service(StudioSVC.class).open(commandSender, seed, hostDimension.getLoadKey(), world -> {
if (world == null) return;
try {
WorldRuntimeControlService.get().applyObjectStudioWorldRules(world);
} catch (Throwable e) {
Iris.reportError("Failed to apply object studio world rules for " + world.getName(), e);
}
if (commandSender.isPlayer()) {
Player p = commandSender.player();
if (p != null) {
Location target = new Location(world, 0.5D, 66D, 0.5D);
J.runEntity(p, () -> {
PaperLib.teleportAsync(p, target).thenRun(() -> p.setGameMode(GameMode.CREATIVE));
});
}
}
});
} catch (Throwable e) {
Iris.reportError("Failed to open object studio world \"" + finalHost.getLoadKey() + "\".", e);
commandSender.sendMessage(C.RED + "Failed to open object studio: " + e.getMessage());
}
}
@Director(description = "Open VSCode for a dimension", aliases = {"vsc", "edit"})
public void vscode(
@Param(defaultValue = "default", description = "The dimension to open VSCode for", aliases = "dim", customHandler = DimensionHandler.class)
@@ -0,0 +1,67 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.runtime;
import art.arcane.iris.core.loader.IrisData;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class ObjectStudioActivation {
private static final Set<String> ACTIVE = Collections.newSetFromMap(new ConcurrentHashMap<>());
private static final Map<String, Map<String, IrisData>> SOURCES = new ConcurrentHashMap<>();
private ObjectStudioActivation() {
}
public static void activate(String packKey) {
if (packKey == null) return;
ACTIVE.add(normalize(packKey));
}
public static void deactivate(String packKey) {
if (packKey == null) return;
String norm = normalize(packKey);
ACTIVE.remove(norm);
SOURCES.remove(norm);
}
public static boolean isActive(String packKey) {
if (packKey == null) return false;
return ACTIVE.contains(normalize(packKey));
}
public static void setSources(String packKey, Map<String, IrisData> sources) {
if (packKey == null || sources == null || sources.isEmpty()) return;
SOURCES.put(normalize(packKey), new LinkedHashMap<>(sources));
}
public static Map<String, IrisData> getSources(String packKey) {
if (packKey == null) return null;
return SOURCES.get(normalize(packKey));
}
private static String normalize(String key) {
return key.toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,258 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.runtime;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.bukkit.util.BlockVector;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class ObjectStudioLayout {
public static final int FLOOR_Y = 64;
private static final int DEFAULT_ROW_WIDTH_CAP = 160;
private final int padding;
private final int rowWidthCap;
private final List<GridCell> cells;
private final Map<String, GridCell> byKey;
private ObjectStudioLayout(int padding, int rowWidthCap, List<GridCell> cells) {
this.padding = padding;
this.rowWidthCap = rowWidthCap;
this.cells = Collections.unmodifiableList(cells);
Map<String, GridCell> index = new LinkedHashMap<>();
for (GridCell cell : cells) {
index.putIfAbsent(cell.key(), cell);
index.putIfAbsent(cell.pack() + "/" + cell.key(), cell);
}
this.byKey = Collections.unmodifiableMap(index);
}
public static ObjectStudioLayout build(IrisData data, int padding) {
Map<String, IrisData> sources = new LinkedHashMap<>();
sources.put(data.getDataFolder().getName(), data);
return build(sources, padding, DEFAULT_ROW_WIDTH_CAP);
}
public static ObjectStudioLayout build(Map<String, IrisData> sources, int padding) {
return build(sources, padding, DEFAULT_ROW_WIDTH_CAP);
}
public static ObjectStudioLayout build(Map<String, IrisData> sources, int padding, int rowWidthCap) {
List<PackEntry> entries = new ArrayList<>();
for (Map.Entry<String, IrisData> entry : sources.entrySet()) {
String packName = entry.getKey();
IrisData data = entry.getValue();
String[] possible = data.getObjectLoader().getPossibleKeys();
if (possible == null) continue;
List<String> sorted = new ArrayList<>(Arrays.asList(possible));
Collections.sort(sorted);
for (String key : sorted) {
entries.add(new PackEntry(packName, data, key));
}
}
entries.sort((a, b) -> {
int c = a.pack.compareTo(b.pack);
if (c != 0) return c;
return a.key.compareTo(b.key);
});
List<GridCell> packed = new ArrayList<>(entries.size());
int cursorX = 0;
int rowZ = 0;
int rowMaxDepth = 0;
for (PackEntry entry : entries) {
File file = entry.data.getObjectLoader().findFile(entry.key);
if (file == null) {
continue;
}
BlockVector size;
try {
size = IrisObject.sampleSize(file);
} catch (Throwable e) {
Iris.reportError(e);
continue;
}
int w = Math.max(1, size.getBlockX());
int h = Math.max(1, size.getBlockY());
int d = Math.max(1, size.getBlockZ());
int cellWidth = w + padding * 2;
int cellDepth = d + padding * 2;
if (cursorX > 0 && cursorX + cellWidth > rowWidthCap) {
rowZ += rowMaxDepth;
cursorX = 0;
rowMaxDepth = 0;
}
int originX = cursorX + padding;
int originZ = rowZ + padding;
int originY = FLOOR_Y + 1;
packed.add(new GridCell(entry.pack, entry.key, originX, originY, originZ, w, h, d));
cursorX += cellWidth;
rowMaxDepth = Math.max(rowMaxDepth, cellDepth);
}
return new ObjectStudioLayout(padding, rowWidthCap, packed);
}
public static ObjectStudioLayout load(File file, Map<String, IrisData> sources, int padding) {
if (file == null || !file.exists()) {
return null;
}
try {
String raw = Files.readString(file.toPath());
JSONObject root = new JSONObject(raw);
int storedPadding = root.optInt("padding", padding);
int storedCap = root.optInt("rowWidthCap", DEFAULT_ROW_WIDTH_CAP);
JSONArray arr = root.getJSONArray("cells");
List<GridCell> stored = new ArrayList<>();
Set<String> storedIds = new HashSet<>();
for (int i = 0; i < arr.length(); i++) {
JSONObject c = arr.getJSONObject(i);
String pack = c.optString("pack", null);
if (pack == null || pack.isEmpty()) {
return null;
}
GridCell cell = new GridCell(
pack,
c.getString("key"),
c.getInt("x"),
c.getInt("y"),
c.getInt("z"),
c.getInt("w"),
c.getInt("h"),
c.getInt("d")
);
stored.add(cell);
storedIds.add(cell.pack() + "/" + cell.key());
}
Set<String> liveIds = new HashSet<>();
for (Map.Entry<String, IrisData> entry : sources.entrySet()) {
String[] live = entry.getValue().getObjectLoader().getPossibleKeys();
if (live == null) continue;
for (String k : live) {
liveIds.add(entry.getKey() + "/" + k);
}
}
if (liveIds.size() == storedIds.size() && liveIds.containsAll(storedIds)) {
return new ObjectStudioLayout(storedPadding, storedCap, stored);
}
return null;
} catch (Throwable e) {
Iris.reportError(e);
return null;
}
}
public void save(File file) {
if (file == null) {
return;
}
try {
if (file.getParentFile() != null) {
file.getParentFile().mkdirs();
}
JSONObject root = new JSONObject();
root.put("padding", padding);
root.put("rowWidthCap", rowWidthCap);
JSONArray arr = new JSONArray();
for (GridCell cell : cells) {
JSONObject c = new JSONObject();
c.put("pack", cell.pack());
c.put("key", cell.key());
c.put("x", cell.originX());
c.put("y", cell.originY());
c.put("z", cell.originZ());
c.put("w", cell.w());
c.put("h", cell.h());
c.put("d", cell.d());
arr.put(c);
}
root.put("cells", arr);
Files.writeString(file.toPath(), root.toString(2));
} catch (Throwable e) {
Iris.reportError(e);
}
}
public int padding() {
return padding;
}
public List<GridCell> cells() {
return cells;
}
public GridCell findAt(int worldX, int worldZ) {
for (GridCell cell : cells) {
if (worldX >= cell.originX() && worldX < cell.originX() + cell.w()
&& worldZ >= cell.originZ() && worldZ < cell.originZ() + cell.d()) {
return cell;
}
}
return null;
}
public GridCell get(String key) {
return byKey.get(key);
}
private record PackEntry(String pack, IrisData data, String key) {
}
public record GridCell(String pack, String key, int originX, int originY, int originZ, int w, int h, int d) {
public int chunkMinX() {
return originX >> 4;
}
public int chunkMaxX() {
return (originX + w - 1) >> 4;
}
public int chunkMinZ() {
return originZ >> 4;
}
public int chunkMaxZ() {
return (originZ + d - 1) >> 4;
}
}
}
@@ -92,6 +92,35 @@ public final class WorldRuntimeControlService {
return true;
}
public boolean applyObjectStudioWorldRules(World world) {
if (world == null) {
return false;
}
applyStudioWorldRules(world);
setBooleanGameRule(world, false, "DO_FIRE_TICK", "doFireTick");
setBooleanGameRule(world, false, "DO_MOB_SPAWNING", "doMobSpawning");
setBooleanGameRule(world, false, "DO_MOB_LOOT", "doMobLoot");
setBooleanGameRule(world, false, "DO_TRADER_SPAWNING", "doTraderSpawning");
setBooleanGameRule(world, false, "DO_PATROL_SPAWNING", "doPatrolSpawning");
setBooleanGameRule(world, false, "DO_INSOMNIA", "doInsomnia");
setBooleanGameRule(world, true, "DO_IMMEDIATE_RESPAWN", "doImmediateRespawn");
setBooleanGameRule(world, false, "FALL_DAMAGE", "fallDamage");
setBooleanGameRule(world, false, "FIRE_DAMAGE", "fireDamage");
setBooleanGameRule(world, false, "DROWNING_DAMAGE", "drowningDamage");
setBooleanGameRule(world, false, "FREEZE_DAMAGE", "freezeDamage");
setBooleanGameRule(world, false, "DO_WARDEN_SPAWNING", "doWardenSpawning");
setBooleanGameRule(world, false, "MOB_GRIEFING", "mobGriefing");
setBooleanGameRule(world, false, "DO_TILE_DROPS", "doTileDrops");
setBooleanGameRule(world, true, "KEEP_INVENTORY", "keepInventory");
setIntGameRule(world, 0, "RANDOM_TICK_SPEED", "randomTickSpeed");
setIntGameRule(world, 0, "SPAWN_RADIUS", "spawnRadius");
setIntGameRule(world, 0, "MAX_ENTITY_CRAMMING", "maxEntityCramming");
applyNoonTimeLock(world);
return true;
}
public boolean applyNoonTimeLock(World world) {
if (world == null) {
return false;
@@ -347,6 +376,74 @@ public final class WorldRuntimeControlService {
}
}
@SuppressWarnings("unchecked")
private static void setIntGameRule(World world, int value, String... names) {
GameRule<Integer> gameRule = resolveIntGameRule(world, names);
if (gameRule != null) {
world.setGameRule(gameRule, value);
}
}
@SuppressWarnings("unchecked")
private static GameRule<Integer> resolveIntGameRule(World world, String... names) {
if (world == null || names == null || names.length == 0) {
return null;
}
Set<String> candidates = buildRuleNameCandidates(names);
for (String name : candidates) {
if (name == null || name.isBlank()) {
continue;
}
try {
Field field = GameRule.class.getField(name);
Object value = field.get(null);
if (value instanceof GameRule<?> gameRule && Integer.class.equals(gameRule.getType())) {
return (GameRule<Integer>) gameRule;
}
} catch (Throwable ignored) {
}
try {
GameRule<?> byName = GameRule.getByName(name);
if (byName != null && Integer.class.equals(byName.getType())) {
return (GameRule<Integer>) byName;
}
} catch (Throwable ignored) {
}
}
String[] availableRules = world.getGameRules();
if (availableRules == null || availableRules.length == 0) {
return null;
}
Set<String> normalizedCandidates = new LinkedHashSet<>();
for (String candidate : candidates) {
if (candidate != null && !candidate.isBlank()) {
normalizedCandidates.add(normalizeRuleName(candidate));
}
}
for (String availableRule : availableRules) {
String normalizedAvailable = normalizeRuleName(availableRule);
if (!normalizedCandidates.contains(normalizedAvailable)) {
continue;
}
try {
GameRule<?> byName = GameRule.getByName(availableRule);
if (byName != null && Integer.class.equals(byName.getType())) {
return (GameRule<Integer>) byName;
}
} catch (Throwable ignored) {
}
}
return null;
}
@SuppressWarnings("unchecked")
private static GameRule<Boolean> resolveBooleanGameRule(World world, String... names) {
if (world == null || names == null || names.length == 0) {
@@ -173,13 +173,19 @@ public class BoardSVC implements IrisService, BoardProvider {
}
private void tick() {
if (cancelled || !boardEnabled || !player.isOnline()) {
if (!boardEnabled || !player.isOnline()) {
return;
}
if (cancelled) {
board.remove();
return;
}
if (!isEligibleWorld(player)) {
boards.remove(player);
cancel();
cancelled = true;
board.remove();
return;
}
@@ -189,8 +195,15 @@ public class BoardSVC implements IrisService, BoardProvider {
}
public void cancel() {
if (cancelled) {
return;
}
cancelled = true;
J.runEntity(player, board::remove);
if (J.isOwnedByCurrentRegion(player) && player.isOnline()) {
board.remove();
} else {
J.runEntity(player, board::remove);
}
}
public void update() {
@@ -0,0 +1,347 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.service;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.runtime.ObjectStudioActivation;
import art.arcane.iris.core.runtime.ObjectStudioLayout;
import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator;
import art.arcane.iris.util.common.plugin.IrisService;
import art.arcane.iris.util.common.scheduling.J;
import io.papermc.lib.PaperLib;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.EntitySpawnEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class ObjectStudioSaveService implements IrisService {
public static final int INTERVAL_TICKS = 100;
private static final int CELLS_PER_PASS = 50;
private static ObjectStudioSaveService INSTANCE;
private final Map<UUID, ActiveStudio> studios = new ConcurrentHashMap<>();
private int taskId = -1;
public static ObjectStudioSaveService get() {
ObjectStudioSaveService svc = INSTANCE;
if (svc != null) return svc;
svc = Iris.service(ObjectStudioSaveService.class);
return svc;
}
@Override
public void onEnable() {
INSTANCE = this;
taskId = J.ar(this::pass, INTERVAL_TICKS);
}
@Override
public void onDisable() {
if (taskId != -1) {
J.car(taskId);
taskId = -1;
}
studios.clear();
INSTANCE = null;
}
public void register(Engine engine, ObjectStudioGenerator generator) {
World world = engine.getTarget().getWorld().realWorld();
if (world == null) return;
ObjectStudioLayout layout = generator.getLayout();
if (layout == null) return;
Map<String, IrisData> sources = generator.getPackData();
if (sources == null || sources.isEmpty()) {
Iris.warn("Object Studio save disabled: no pack data sources available for world %s", world.getName());
return;
}
Map<String, File> objectsDirs = new ConcurrentHashMap<>();
for (Map.Entry<String, IrisData> e : sources.entrySet()) {
File dir = resolveObjectsDir(e.getValue());
if (dir != null) {
objectsDirs.put(e.getKey(), dir);
}
}
if (objectsDirs.isEmpty()) {
Iris.warn("Object Studio save disabled: no resolvable objects folders for world %s", world.getName());
return;
}
ActiveStudio existing = studios.get(world.getUID());
if (existing != null && existing.layout == layout) {
return;
}
String packKey = engine.getDimension() == null ? null : engine.getDimension().getLoadKey();
studios.put(world.getUID(), new ActiveStudio(world.getUID(), layout, objectsDirs, packKey));
Iris.info("Object Studio live-save registered: world=%s cells=%d packs=%d",
world.getName(), layout.cells().size(), objectsDirs.size());
}
public void unregister(World world) {
if (world == null) return;
ActiveStudio removed = studios.remove(world.getUID());
if (removed != null) {
if (removed.packKey != null) {
ObjectStudioActivation.deactivate(removed.packKey);
}
Iris.info("Object Studio live-save unregistered: world=%s", world.getName());
}
}
@EventHandler
public void onWorldUnload(WorldUnloadEvent event) {
unregister(event.getWorld());
}
@EventHandler(ignoreCancelled = true)
public void onCreatureSpawn(CreatureSpawnEvent event) {
if (!studios.containsKey(event.getLocation().getWorld().getUID())) return;
CreatureSpawnEvent.SpawnReason reason = event.getSpawnReason();
if (reason == CreatureSpawnEvent.SpawnReason.CUSTOM
|| reason == CreatureSpawnEvent.SpawnReason.COMMAND
|| reason == CreatureSpawnEvent.SpawnReason.SPAWNER_EGG) {
return;
}
event.setCancelled(true);
}
@EventHandler(ignoreCancelled = true)
public void onEntitySpawn(EntitySpawnEvent event) {
if (event instanceof CreatureSpawnEvent) return;
if (!studios.containsKey(event.getLocation().getWorld().getUID())) return;
if (event.getEntity() instanceof org.bukkit.entity.Player) return;
event.setCancelled(true);
}
@EventHandler(ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.LEFT_CLICK_BLOCK) return;
Block clicked = event.getClickedBlock();
if (clicked == null) return;
World world = clicked.getWorld();
ActiveStudio studio = studios.get(world.getUID());
if (studio == null) return;
GridCell cell = studio.layout.findAt(clicked.getX(), clicked.getZ());
if (cell == null) return;
Player player = event.getPlayer();
Iris.info("Object Studio save triggered by %s for %s", player.getName(), cell.key());
J.runRegion(world, cell.chunkMinX(), cell.chunkMinZ(), () -> {
try {
captureAndSave(studio, world, cell);
} catch (Throwable e) {
Iris.reportError(e);
}
});
}
public boolean teleportTo(Player player, String objectKey) {
if (player == null || objectKey == null) return false;
for (ActiveStudio studio : studios.values()) {
GridCell cell = studio.layout.get(objectKey);
if (cell == null) continue;
World world = Bukkit.getWorld(studio.worldId);
if (world == null) continue;
double targetX = cell.originX() + cell.w() / 2.0D + 0.5D;
double targetZ = cell.originZ() + cell.d() / 2.0D + 0.5D;
double targetY = cell.originY() + cell.h() + 2.0D;
Location location = new Location(world, targetX, targetY, targetZ);
J.runEntity(player, () -> PaperLib.teleportAsync(player, location));
Iris.info("Object Studio goto: %s -> %s at %.0f,%.0f,%.0f",
player.getName(), objectKey, location.getX(), location.getY(), location.getZ());
return true;
}
return false;
}
private static File resolveObjectsDir(IrisData data) {
File root = data.getDataFolder();
if (root == null) return null;
File objects = new File(root, "objects");
if (!objects.exists()) {
objects.mkdirs();
}
return objects;
}
private void pass() {
if (studios.isEmpty()) return;
for (ActiveStudio studio : studios.values()) {
World world = Bukkit.getWorld(studio.worldId);
if (world == null) continue;
int budget = CELLS_PER_PASS;
int size = studio.layout.cells().size();
if (size == 0) continue;
while (budget-- > 0) {
int idx = studio.cursor.getAndIncrement();
if (idx >= size) {
studio.cursor.set(0);
idx = 0;
}
GridCell cell = studio.layout.cells().get(idx);
scheduleCapture(studio, world, cell);
if (size <= CELLS_PER_PASS && idx == size - 1) break;
}
}
}
private void scheduleCapture(ActiveStudio studio, World world, GridCell cell) {
int chunkX = cell.chunkMinX();
int chunkZ = cell.chunkMinZ();
J.runRegion(world, chunkX, chunkZ, () -> {
try {
captureAndSave(studio, world, cell);
} catch (Throwable e) {
Iris.reportError(e);
}
});
}
private void captureAndSave(ActiveStudio studio, World world, GridCell cell) {
if (!allChunksLoaded(world, cell)) {
return;
}
IrisObject snapshot = new IrisObject(cell.w(), cell.h(), cell.d());
int originX = cell.originX();
int originY = cell.originY();
int originZ = cell.originZ();
boolean anyBlock = false;
for (int dx = 0; dx < cell.w(); dx++) {
for (int dy = 0; dy < cell.h(); dy++) {
for (int dz = 0; dz < cell.d(); dz++) {
Block block = world.getBlockAt(originX + dx, originY + dy, originZ + dz);
if (block.getType() == Material.AIR) continue;
snapshot.setUnsigned(dx, dy, dz, block, false);
anyBlock = true;
}
}
}
String hashKey = cell.pack() + "/" + cell.key();
long hash = hashOf(snapshot);
Long prior = studio.hashes.get(hashKey);
if (prior != null && prior == hash) {
return;
}
if (!anyBlock && prior == null) {
studio.hashes.put(hashKey, hash);
return;
}
studio.hashes.put(hashKey, hash);
File targetFile = objectFileFor(studio, cell);
if (targetFile == null) return;
J.a(() -> {
try {
File parent = targetFile.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
snapshot.write(targetFile);
Iris.info("Object Studio saved: %s/%s (%dx%dx%d)",
cell.pack(), cell.key(), cell.w(), cell.h(), cell.d());
} catch (Throwable e) {
Iris.reportError(e);
}
});
}
private boolean allChunksLoaded(World world, GridCell cell) {
for (int cx = cell.chunkMinX(); cx <= cell.chunkMaxX(); cx++) {
for (int cz = cell.chunkMinZ(); cz <= cell.chunkMaxZ(); cz++) {
if (!world.isChunkLoaded(cx, cz)) {
return false;
}
}
}
return true;
}
private static long hashOf(IrisObject snapshot) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
snapshot.write(baos);
byte[] bytes = baos.toByteArray();
long h = 1125899906842597L;
for (byte b : bytes) {
h = 31 * h + b;
}
return h;
} catch (Throwable e) {
Iris.reportError(e);
return System.nanoTime();
}
}
private static File objectFileFor(ActiveStudio studio, GridCell cell) {
File objectsDir = studio.objectsDirs.get(cell.pack());
if (objectsDir == null) return null;
String relative = cell.key().replace('\\', '/');
return new File(objectsDir, relative + ".iob");
}
private static final class ActiveStudio {
final UUID worldId;
final ObjectStudioLayout layout;
final Map<String, File> objectsDirs;
final String packKey;
final Map<String, Long> hashes = new ConcurrentHashMap<>();
final AtomicInteger cursor = new AtomicInteger();
ActiveStudio(UUID worldId, ObjectStudioLayout layout, Map<String, File> objectsDirs, String packKey) {
this.worldId = worldId;
this.layout = layout;
this.objectsDirs = objectsDirs;
this.packKey = packKey;
}
}
}
@@ -0,0 +1,544 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.tools;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.common.data.VectorMap;
import org.bukkit.Axis;
import org.bukkit.Tag;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Orientable;
import org.bukkit.block.data.type.Leaves;
import org.bukkit.util.BlockVector;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class TreePlausibilizer {
public static final int MAX_DISTANCE = 6;
public static final int DEFAULT_SHELL_RADIUS = 2;
private static final int[][] NEIGHBORS = {
{1, 0, 0}, {-1, 0, 0},
{0, 1, 0}, {0, -1, 0},
{0, 0, 1}, {0, 0, -1}
};
private static final BlockData FALLBACK_LOG = B.get("minecraft:oak_log[axis=y]");
private static final BlockData FALLBACK_LEAF = B.get("minecraft:oak_leaves[distance=1,persistent=false,waterlogged=false]");
private TreePlausibilizer() {
}
public record Result(
int totalLeaves,
int persistentLeavesInput,
int reachableBefore,
int logsAdded,
int leavesAdded,
int leavesRemoved,
int leavesNormalized,
int unreachableAfter,
String skipReason
) {
public static Result skipped(String reason) {
return new Result(0, 0, 0, 0, 0, 0, 0, 0, reason);
}
}
public static Result analyze(IrisObject obj, boolean normalize, boolean smoke, int shellRadius) {
return run(obj, false, normalize, smoke, shellRadius);
}
public static Result apply(IrisObject obj, boolean normalize, boolean smoke, int shellRadius) {
return run(obj, true, normalize, smoke, shellRadius);
}
private static Result run(IrisObject obj, boolean mutate, boolean normalize, boolean smoke, int shellRadius) {
VectorMap<BlockData> blocks = obj.getBlocks();
Map<Long, BlockData> positions = new HashMap<>(blocks.size() * 2);
Set<Long> logPositions = new HashSet<>();
Set<Long> originalLeafPositions = new HashSet<>();
Set<Long> persistentLeafPositions = new HashSet<>();
Map<BlockData, Integer> leafTypeCounts = new HashMap<>();
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
for (Map.Entry<BlockVector, BlockData> entry : blocks) {
BlockVector pos = entry.getKey();
BlockData data = entry.getValue();
long key = packKey(pos);
positions.put(key, data);
int x = pos.getBlockX();
int y = pos.getBlockY();
int z = pos.getBlockZ();
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
if (isLog(data)) {
logPositions.add(key);
continue;
}
if (isLeaf(data)) {
boolean persistent = data instanceof Leaves leaves && leaves.isPersistent();
if (persistent) {
persistentLeafPositions.add(key);
}
originalLeafPositions.add(key);
leafTypeCounts.merge(data, 1, Integer::sum);
}
}
int persistentInput = persistentLeafPositions.size();
int totalLeavesInitial = originalLeafPositions.size();
if (logPositions.isEmpty() && !originalLeafPositions.isEmpty()) {
return Result.skipped("leaves present but no logs to bridge from");
}
if (logPositions.isEmpty() && !smoke) {
return new Result(0, 0, 0, 0, 0, 0, 0, 0, null);
}
Set<Long> leafPositions;
int leavesRemoved = 0;
List<Long> removedLeafKeys = new ArrayList<>();
if (smoke) {
leafPositions = new HashSet<>();
for (long key : originalLeafPositions) {
removedLeafKeys.add(key);
positions.remove(key);
}
leavesRemoved = removedLeafKeys.size();
int r = Math.max(0, Math.min(shellRadius, 5));
BlockData leafTemplate = pickDominantLeaf(leafTypeCounts);
paintShell(
logPositions, positions, leafPositions,
leafTemplate, r,
minX, minY, minZ, maxX, maxY, maxZ
);
} else if (normalize) {
leafPositions = new HashSet<>(originalLeafPositions);
} else {
leafPositions = new HashSet<>(originalLeafPositions);
leafPositions.removeAll(persistentLeafPositions);
}
int reachableBefore;
int logsAdded = 0;
Set<Long> unreached;
Map<Long, Integer> distances;
List<LogInsertion> inserts = new ArrayList<>();
if (!leafPositions.isEmpty() && !logPositions.isEmpty()) {
Set<Long> connectivityLeaves;
if (!normalize && !smoke) {
connectivityLeaves = new HashSet<>(leafPositions);
connectivityLeaves.addAll(persistentLeafPositions);
} else {
connectivityLeaves = leafPositions;
}
distances = seedDistances(logPositions, connectivityLeaves);
reachableBefore = countReachable(leafPositions, distances);
unreached = new HashSet<>(leafPositions);
unreached.removeAll(distances.keySet());
Set<Long> frontier = computeInitialFrontier(unreached, logPositions, distances);
logsAdded = bridgeLoop(
unreached, frontier, distances,
logPositions, leafPositions, connectivityLeaves, positions,
inserts
);
} else {
distances = new HashMap<>();
unreached = new HashSet<>();
reachableBefore = 0;
}
int leavesAdded = 0;
List<LeafAddition> leafAdds = new ArrayList<>();
if (smoke) {
BlockData leafTemplate = pickDominantLeaf(leafTypeCounts);
for (long key : leafPositions) {
leafAdds.add(new LeafAddition(key, leafTemplate));
}
leavesAdded = leafAdds.size();
}
int leavesNormalized = 0;
List<LeafRewrite> normalizeRewrites = new ArrayList<>();
if (normalize && !smoke) {
for (long pos : leafPositions) {
BlockData data = positions.get(pos);
if (data instanceof Leaves leaves && leaves.isPersistent()) {
BlockData cloned = data.clone();
((Leaves) cloned).setPersistent(false);
normalizeRewrites.add(new LeafRewrite(pos, cloned));
leavesNormalized++;
}
}
}
if (mutate) {
if (smoke) {
for (long key : removedLeafKeys) {
if (!positions.containsKey(key)) {
blocks.remove(unpackKey(key));
}
}
}
for (LeafAddition addition : leafAdds) {
blocks.put(unpackKey(addition.key()), addition.data());
}
for (LogInsertion insertion : inserts) {
blocks.put(unpackKey(insertion.key()), insertion.data());
}
for (LeafRewrite rewrite : normalizeRewrites) {
blocks.put(unpackKey(rewrite.key()), rewrite.data());
}
}
int finalLeafCount = leafPositions.size();
if (!normalize && !smoke) {
finalLeafCount += persistentInput;
}
return new Result(
smoke ? leavesAdded : totalLeavesInitial,
persistentInput,
reachableBefore,
logsAdded,
leavesAdded,
leavesRemoved,
leavesNormalized,
unreached.size(),
null
);
}
private static void paintShell(
Set<Long> logPositions,
Map<Long, BlockData> positions,
Set<Long> leafPositions,
BlockData leafTemplate,
int radius,
int minX, int minY, int minZ, int maxX, int maxY, int maxZ
) {
if (radius <= 0) {
return;
}
int r2 = radius * radius;
int bxMin = minX;
int byMin = minY;
int bzMin = minZ;
int bxMax = maxX;
int byMax = maxY;
int bzMax = maxZ;
for (long log : logPositions) {
int[] lx = unpack(log);
for (int dx = -radius; dx <= radius; dx++) {
int ax = lx[0] + dx;
if (ax < bxMin || ax > bxMax) continue;
int dx2 = dx * dx;
for (int dy = -radius; dy <= radius; dy++) {
int ay = lx[1] + dy;
if (ay < byMin || ay > byMax) continue;
int dy2 = dy * dy;
int partial = dx2 + dy2;
if (partial > r2) continue;
for (int dz = -radius; dz <= radius; dz++) {
if (partial + dz * dz > r2) continue;
int az = lx[2] + dz;
if (az < bzMin || az > bzMax) continue;
long nk = packXYZ(ax, ay, az);
if (logPositions.contains(nk)) continue;
if (positions.containsKey(nk)) continue;
positions.put(nk, leafTemplate);
leafPositions.add(nk);
}
}
}
}
}
private static int bridgeLoop(
Set<Long> unreached,
Set<Long> frontier,
Map<Long, Integer> distances,
Set<Long> logPositions,
Set<Long> leafPositions,
Set<Long> connectivityLeaves,
Map<Long, BlockData> positions,
List<LogInsertion> inserts
) {
int logsAdded = 0;
int safetyLimit = unreached.size() + 32;
while (!unreached.isEmpty() && logsAdded < safetyLimit) {
long candidateKey = pickInteriorCandidate(frontier, unreached, connectivityLeaves);
BlockData logData = pickLogVariant(candidateKey, positions, logPositions);
inserts.add(new LogInsertion(candidateKey, logData));
logPositions.add(candidateKey);
leafPositions.remove(candidateKey);
connectivityLeaves.remove(candidateKey);
distances.remove(candidateKey);
unreached.remove(candidateKey);
frontier.remove(candidateKey);
positions.put(candidateKey, logData);
logsAdded++;
int[] cx = unpack(candidateKey);
Deque<Long> q = new ArrayDeque<>();
for (int[] n : NEIGHBORS) {
long nk = packXYZ(cx[0] + n[0], cx[1] + n[1], cx[2] + n[2]);
if (connectivityLeaves.contains(nk)) {
Integer cur = distances.get(nk);
if (cur == null || cur > 1) {
if (cur == null) {
unreached.remove(nk);
frontier.remove(nk);
}
distances.put(nk, 1);
q.add(nk);
}
}
if (unreached.contains(nk)) {
frontier.add(nk);
}
}
while (!q.isEmpty()) {
long pos = q.poll();
int d = distances.get(pos);
if (d >= MAX_DISTANCE) {
continue;
}
int[] px = unpack(pos);
for (int[] n : NEIGHBORS) {
long nk = packXYZ(px[0] + n[0], px[1] + n[1], px[2] + n[2]);
if (connectivityLeaves.contains(nk)) {
Integer cur = distances.get(nk);
if (cur == null || cur > d + 1) {
if (cur == null) {
unreached.remove(nk);
frontier.remove(nk);
}
distances.put(nk, d + 1);
q.add(nk);
}
}
if (unreached.contains(nk)) {
frontier.add(nk);
}
}
}
}
return logsAdded;
}
private static long pickInteriorCandidate(
Set<Long> frontier, Set<Long> unreached, Set<Long> connectivityLeaves
) {
Set<Long> pool = !frontier.isEmpty() ? frontier : unreached;
long best = -1L;
int bestScore = -1;
for (long pos : pool) {
int[] xyz = unpack(pos);
int score = 0;
for (int[] n : NEIGHBORS) {
long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]);
if (connectivityLeaves.contains(nk)) {
score++;
}
}
if (score > bestScore) {
bestScore = score;
best = pos;
if (score == 6) break;
}
}
return best;
}
private static Set<Long> computeInitialFrontier(
Set<Long> unreached, Set<Long> logPositions, Map<Long, Integer> distances
) {
Set<Long> frontier = new HashSet<>();
for (long u : unreached) {
if (hasReachedNeighbor(u, distances, logPositions)) {
frontier.add(u);
}
}
return frontier;
}
private static boolean hasReachedNeighbor(long key, Map<Long, Integer> distances, Set<Long> logPositions) {
int[] xyz = unpack(key);
for (int[] n : NEIGHBORS) {
long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]);
if (logPositions.contains(nk) || distances.containsKey(nk)) {
return true;
}
}
return false;
}
private static Map<Long, Integer> seedDistances(Set<Long> logPositions, Set<Long> leafPositions) {
Map<Long, Integer> dist = new HashMap<>();
Deque<Long> queue = new ArrayDeque<>();
for (long leaf : leafPositions) {
int[] xyz = unpack(leaf);
for (int[] n : NEIGHBORS) {
long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]);
if (logPositions.contains(nk)) {
dist.put(leaf, 1);
queue.add(leaf);
break;
}
}
}
while (!queue.isEmpty()) {
long pos = queue.poll();
int d = dist.get(pos);
if (d >= MAX_DISTANCE) {
continue;
}
int[] xyz = unpack(pos);
for (int[] n : NEIGHBORS) {
long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]);
if (leafPositions.contains(nk) && !dist.containsKey(nk)) {
dist.put(nk, d + 1);
queue.add(nk);
}
}
}
return dist;
}
private static int countReachable(Set<Long> leafPositions, Map<Long, Integer> distances) {
int count = 0;
for (long leaf : leafPositions) {
if (distances.containsKey(leaf)) {
count++;
}
}
return count;
}
private static BlockData pickDominantLeaf(Map<BlockData, Integer> leafTypeCounts) {
BlockData best = null;
int bestCount = -1;
for (Map.Entry<BlockData, Integer> e : leafTypeCounts.entrySet()) {
if (e.getValue() > bestCount) {
bestCount = e.getValue();
best = e.getKey();
}
}
if (best == null) {
return FALLBACK_LEAF.clone();
}
BlockData clone = best.clone();
if (clone instanceof Leaves leaves) {
leaves.setPersistent(false);
}
return clone;
}
private static BlockData pickLogVariant(long target, Map<Long, BlockData> positions, Set<Long> logPositions) {
if (logPositions.isEmpty()) {
return FALLBACK_LOG.clone();
}
int[] tx = unpack(target);
long nearest = -1L;
long nearestDistSq = Long.MAX_VALUE;
for (long lp : logPositions) {
int[] lx = unpack(lp);
long dx = tx[0] - lx[0];
long dy = tx[1] - lx[1];
long dz = tx[2] - lx[2];
long d2 = dx * dx + dy * dy + dz * dz;
if (d2 < nearestDistSq) {
nearestDistSq = d2;
nearest = lp;
}
}
BlockData source = positions.get(nearest);
if (source == null) {
return FALLBACK_LOG.clone();
}
BlockData clone = source.clone();
if (clone instanceof Orientable orientable) {
orientable.setAxis(Axis.Y);
}
return clone;
}
private static boolean isLog(BlockData data) {
return Tag.LOGS.isTagged(data.getMaterial());
}
private static boolean isLeaf(BlockData data) {
return Tag.LEAVES.isTagged(data.getMaterial()) || data instanceof Leaves;
}
private static long packKey(BlockVector pos) {
return packXYZ(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ());
}
private static long packXYZ(int x, int y, int z) {
long lx = (x + 32768L) & 0xFFFFL;
long ly = (y + 32768L) & 0xFFFFL;
long lz = (z + 32768L) & 0xFFFFL;
return (lx << 32) | (ly << 16) | lz;
}
private static int[] unpack(long key) {
int x = (int) ((key >> 32) & 0xFFFFL) - 32768;
int y = (int) ((key >> 16) & 0xFFFFL) - 32768;
int z = (int) (key & 0xFFFFL) - 32768;
return new int[]{x, y, z};
}
private static BlockVector unpackKey(long key) {
int[] xyz = unpack(key);
return new BlockVector(xyz[0], xyz[1], xyz[2]);
}
private record LogInsertion(long key, BlockData data) {
}
private record LeafAddition(long key, BlockData data) {
}
private record LeafRewrite(long key, BlockData data) {
}
}
@@ -25,6 +25,7 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.events.IrisEngineHotloadEvent;
import art.arcane.iris.core.gui.PregeneratorJob;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.container.BlockPos;
import art.arcane.iris.core.nms.container.Pair;
@@ -106,6 +107,7 @@ public class IrisEngine implements Engine {
private double maxBiomeLayerDensity;
private double maxBiomeDecoratorDensity;
private IrisComplex complex;
private UpperDimensionContext upperContext;
private final AtomicBoolean modeFallbackLogged;
public IrisEngine(EngineTarget target, boolean studio) {
@@ -207,6 +209,7 @@ public class IrisEngine implements Engine {
Iris.debug("Setup Engine " + getCacheID());
cacheId = RNG.r.nextInt();
complex = ensureComplex();
upperContext = buildUpperContext();
effects = new IrisEngineEffects(this);
hash32 = new CompletableFuture<>();
mantle.hotload();
@@ -234,6 +237,25 @@ public class IrisEngine implements Engine {
Iris.debug("Engine Setup Complete " + getCacheID());
}
private UpperDimensionContext buildUpperContext() {
IrisDimension dim = getDimension();
if (!dim.hasUpperDimension()) {
return null;
}
String upperKey = dim.getUpperDimension();
IrisDimension upperDim = upperKey.equals(dim.getLoadKey())
? dim
: IrisData.loadAnyDimension(upperKey, getData());
if (upperDim != null) {
UpperDimensionContext ctx = UpperDimensionContext.create(this, upperDim);
Iris.info("Upper dimension enabled: " + upperKey
+ (ctx.isSelfReferencing() ? " (self-referencing)" : " (cross-referencing)"));
return ctx;
}
Iris.warn("Upper dimension '" + upperKey + "' could not be resolved, skipping upper terrain.");
return null;
}
private void setupMode() {
EngineMode currentMode = mode;
if (currentMode != null) {
@@ -344,6 +366,7 @@ public class IrisEngine implements Engine {
public void hotloadComplex() {
complex.close();
complex = new IrisComplex(this);
upperContext = buildUpperContext();
}
public void hotloadSilently() {
@@ -0,0 +1,266 @@
package art.arcane.iris.engine;
import art.arcane.iris.Iris;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.*;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.iris.util.common.data.DataProvider;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
import org.bukkit.block.data.BlockData;
import java.util.*;
public class UpperDimensionContext implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private final IrisDimension dimension;
private final IrisData data;
private final int chunkHeight;
private final ProceduralStream<Double> heightStream;
private final ProceduralStream<IrisBiome> biomeStream;
private final ProceduralStream<BlockData> rockStream;
private final boolean selfReferencing;
private UpperDimensionContext(IrisDimension dimension, IrisData data, int chunkHeight,
ProceduralStream<Double> heightStream,
ProceduralStream<IrisBiome> biomeStream,
ProceduralStream<BlockData> rockStream,
boolean selfReferencing) {
this.dimension = dimension;
this.data = data;
this.chunkHeight = chunkHeight;
this.heightStream = heightStream;
this.biomeStream = biomeStream;
this.rockStream = rockStream;
this.selfReferencing = selfReferencing;
}
public static UpperDimensionContext create(Engine engine, IrisDimension upperDim) {
boolean selfRef = upperDim.getLoadKey().equals(engine.getDimension().getLoadKey());
int chunkHeight = engine.getHeight();
if (selfRef) {
return createSelfReferencing(engine, chunkHeight);
}
return createCrossReferencing(engine, upperDim, chunkHeight);
}
private static UpperDimensionContext createSelfReferencing(Engine engine, int chunkHeight) {
IrisComplex complex = engine.getComplex();
return new UpperDimensionContext(
engine.getDimension(),
engine.getData(),
chunkHeight,
complex.getHeightStream(),
complex.getBaseBiomeStream(),
complex.getRockStream(),
true
);
}
private static UpperDimensionContext createCrossReferencing(Engine engine, IrisDimension upperDim, int chunkHeight) {
IrisData resolvedData = upperDim.getLoader();
if (resolvedData == null) {
resolvedData = engine.getData();
}
IrisData upperData = resolvedData;
long seedOffset = upperDim.getLoadKey().hashCode();
RNG rng = new RNG(engine.getSeedManager().getComplex() ^ seedOffset);
double fluidHeight = upperDim.getFluidHeight();
DataProvider dataProvider = () -> upperData;
Map<IrisInterpolator, Set<IrisGenerator>> generators = new HashMap<>();
Set<IrisBiome> allBiomes = Collections.newSetFromMap(new IdentityHashMap<>());
upperDim.getRegions().forEach(regionKey -> {
IrisRegion region = upperData.getRegionLoader().load(regionKey);
if (region != null) {
region.getAllBiomes(dataProvider).forEach(biome -> {
allBiomes.add(biome);
biome.getGenerators().forEach(link -> {
IrisGenerator gen = link.getCachedGenerator(dataProvider);
if (gen != null) {
generators.computeIfAbsent(gen.getInterpolator(), k -> new HashSet<>()).add(gen);
}
});
});
}
});
Map<IrisInterpolator, IdentityHashMap<IrisBiome, NoiseBounds>> generatorBounds = new HashMap<>();
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) {
IdentityHashMap<IrisBiome, NoiseBounds> interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16));
for (IrisBiome biome : allBiomes) {
double min = 0D;
double max = 0D;
for (IrisGenerator gen : entry.getValue()) {
String key = gen.getLoadKey();
if (key == null || key.isBlank()) {
continue;
}
max += biome.getGenLinkMax(key, engine);
min += biome.getGenLinkMin(key, engine);
}
interpolatorBounds.put(biome, new NoiseBounds(min, max));
}
generatorBounds.put(entry.getKey(), interpolatorBounds);
}
ProceduralStream<Double> regionStyleStream = upperDim.getRegionStyle()
.create(rng.nextParallelRNG(883), upperData).stream()
.zoom(upperDim.getRegionZoom());
ProceduralStream<IrisRegion> regionStream = regionStyleStream
.selectRarity(upperData.getRegionLoader().loadAll(upperDim.getRegions()));
ProceduralStream<IrisBiome> landBiomeStream = regionStream
.convert(r -> upperDim.getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), upperData).stream()
.zoom(upperDim.getBiomeZoom())
.zoom(upperDim.getLandZoom())
.zoom(r.getLandBiomeZoom())
.selectRarity(upperData.getBiomeLoader().loadAll(r.getLandBiomes(),
t -> t.setInferredType(InferredType.LAND))))
.convertAware2D(ProceduralStream::get);
ProceduralStream<IrisBiome> seaBiomeStream = regionStream
.convert(r -> upperDim.getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), upperData).stream()
.zoom(upperDim.getBiomeZoom())
.zoom(upperDim.getSeaZoom())
.zoom(r.getSeaBiomeZoom())
.selectRarity(upperData.getBiomeLoader().loadAll(r.getSeaBiomes(),
t -> t.setInferredType(InferredType.SEA))))
.convertAware2D(ProceduralStream::get);
ProceduralStream<IrisBiome> shoreBiomeStream = regionStream
.convert(r -> upperDim.getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), upperData).stream()
.zoom(upperDim.getBiomeZoom())
.zoom(r.getShoreBiomeZoom())
.selectRarity(upperData.getBiomeLoader().loadAll(r.getShoreBiomes(),
t -> t.setInferredType(InferredType.SHORE))))
.convertAware2D(ProceduralStream::get);
Map<InferredType, ProceduralStream<IrisBiome>> inferredStreams = new HashMap<>();
inferredStreams.put(InferredType.LAND, landBiomeStream);
inferredStreams.put(InferredType.SEA, seaBiomeStream);
inferredStreams.put(InferredType.SHORE, shoreBiomeStream);
ProceduralStream<InferredType> bridgeStream = upperDim.getContinentalStyle()
.create(rng.nextParallelRNG(234234565), upperData)
.bake().scale(1D / upperDim.getContinentZoom()).bake().stream()
.convert(v -> v >= upperDim.getLandChance() ? InferredType.SEA : InferredType.LAND);
ProceduralStream<IrisBiome> baseBiomeStream = bridgeStream
.convertAware2D((t, x, z) -> {
ProceduralStream<IrisBiome> stream = inferredStreams.get(t);
return stream != null ? stream.get(x, z) : inferredStreams.get(InferredType.LAND).get(x, z);
});
KList<IrisShapedGeneratorStyle> overlayNoise = upperDim.getOverlayNoise();
ProceduralStream<Double> overlayStream = overlayNoise.isEmpty()
? ProceduralStream.ofDouble((x, z) -> 0.0D)
: ProceduralStream.ofDouble((x, z) -> {
double value = 0D;
for (IrisShapedGeneratorStyle style : overlayNoise) {
value += style.get(rng, upperData, x, z);
}
return value;
});
long heightSeed = engine.getSeedManager().getHeight() ^ seedOffset;
ProceduralStream<Double> heightStream = ProceduralStream.of((x, z) -> {
IrisBiome b = baseBiomeStream.get(x, z);
if (b == null) {
return fluidHeight;
}
double interpolatedHeight = 0;
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) {
IrisInterpolator interpolator = entry.getKey();
Set<IrisGenerator> gens = entry.getValue();
if (gens.isEmpty()) {
continue;
}
IdentityHashMap<IrisBiome, NoiseBounds> cachedBounds = generatorBounds.get(interpolator);
NoiseBounds sampledBounds = interpolator.interpolateBounds(x, z, (xx, zz) -> {
try {
IrisBiome bx = baseBiomeStream.get(xx, zz);
if (bx == null) {
return ZERO_NOISE_BOUNDS;
}
NoiseBounds bounds = cachedBounds != null ? cachedBounds.get(bx) : null;
if (bounds != null) {
return bounds;
}
double bMin = 0D;
double bMax = 0D;
for (IrisGenerator gen : gens) {
String key = gen.getLoadKey();
if (key == null || key.isBlank()) {
continue;
}
bMax += bx.getGenLinkMax(key, engine);
bMin += bx.getGenLinkMin(key, engine);
}
return new NoiseBounds(bMin, bMax);
} catch (Throwable e) {
Iris.reportError(e);
return ZERO_NOISE_BOUNDS;
}
});
double hi = sampledBounds.max();
double lo = sampledBounds.min();
double d = 0;
for (IrisGenerator gen : gens) {
d += M.lerp(lo, hi, gen.getHeight(x, z, heightSeed + 239945));
}
interpolatedHeight += d / gens.size();
}
return Math.max(Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), 0);
}, Interpolated.DOUBLE);
ProceduralStream<BlockData> rockStream = upperDim.getRockPalette()
.getLayerGenerator(rng.nextParallelRNG(45), upperData).stream()
.select(upperDim.getRockPalette().getBlockData(upperData));
return new UpperDimensionContext(
upperDim,
upperData,
chunkHeight,
heightStream,
baseBiomeStream,
rockStream,
false
);
}
public int getUpperSurfaceY(int x, int z) {
double rawHeight = heightStream.get((double) x, (double) z);
return chunkHeight - 1 - (int) Math.round(rawHeight);
}
public IrisBiome getUpperBiome(int x, int z) {
return biomeStream.get((double) x, (double) z);
}
public BlockData getRockBlock(int x, int z) {
return rockStream.get((double) x, (double) z);
}
public IrisDimension getDimension() {
return dimension;
}
@Override
public IrisData getData() {
return data;
}
public boolean isSelfReferencing() {
return selfReferencing;
}
}
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
@@ -167,6 +168,39 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<BlockData>
}
}
}
UpperDimensionContext upperContext = getEngine().getUpperContext();
if (upperContext != null) {
int chunkHeight = h.getHeight();
boolean bedrockEnabled = getDimension().isBedrock();
int rawUpperSurface = upperContext.getUpperSurfaceY(realX, realZ);
int upperGap = getDimension().getUpperDimensionGap();
int upperSurfaceY = Math.max(rawUpperSurface, he + upperGap);
if (upperSurfaceY < chunkHeight - 1) {
IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ);
BlockData upperRock = upperContext.getRockBlock(realX, realZ);
int upperThickness = chunkHeight - 1 - upperSurfaceY;
KList<BlockData> upperBlocks = upperBiome != null
? upperBiome.generateLayers(upperContext.getDimension(),
realX, realZ, rng, upperThickness, upperThickness,
upperContext.getData(), getComplex())
: null;
for (int y = chunkHeight - 1; y >= upperSurfaceY; y--) {
if (y == chunkHeight - 1 && bedrockEnabled) {
h.setRaw(xf, y, zf, BEDROCK);
continue;
}
int depthFromFace = y - upperSurfaceY;
if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) {
h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace));
} else {
h.setRaw(xf, y, zf, upperRock);
}
}
}
}
}
}
@@ -186,6 +220,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<BlockData>
ChunkedDataCache<BlockData> fluidCache = context.getFluid();
ChunkedDataCache<BlockData> rockCache = context.getRock();
int realX = xf + x;
UpperDimensionContext upperContext = getEngine().getUpperContext();
for (int zf = 0; zf < chunkDepth; zf++) {
int realZ = zf + z;
@@ -261,6 +296,36 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<BlockData>
}
}
}
if (upperContext != null) {
int rawUpperSurface = upperContext.getUpperSurfaceY(realX, realZ);
int upperGap = dimension.getUpperDimensionGap();
int upperSurfaceY = Math.max(rawUpperSurface, he + upperGap);
if (upperSurfaceY < chunkHeight - 1) {
IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ);
BlockData upperRock = upperContext.getRockBlock(realX, realZ);
int upperThickness = chunkHeight - 1 - upperSurfaceY;
KList<BlockData> upperBlocks = upperBiome != null
? upperBiome.generateLayers(upperContext.getDimension(),
realX, realZ, localRng, upperThickness, upperThickness,
upperContext.getData(), complex)
: null;
for (int y = chunkHeight - 1; y >= upperSurfaceY; y--) {
if (y == chunkHeight - 1 && bedrockEnabled) {
h.setRaw(xf, y, zf, BEDROCK);
continue;
}
int depthFromFace = y - upperSurfaceY;
if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) {
h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace));
} else {
h.setRaw(xf, y, zf, upperRock);
}
}
}
}
}
}
}
@@ -32,6 +32,7 @@ import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.mantle.EngineMantle;
@@ -95,6 +96,10 @@ import java.util.stream.Collectors;
public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdater, Renderer, Hotloadable {
IrisComplex getComplex();
default @Nullable UpperDimensionContext getUpperContext() {
return null;
}
EngineMode getMode();
int getBlockUpdatesPerSecond();
@@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.mantle.components.MantleObjectComponent;
@@ -191,9 +192,34 @@ public interface EngineMantle extends MatterGenerator {
return;
}
var chunk = getMantle().getChunk(x, z).use();
UpperDimensionContext upperCtx = getEngine().getUpperContext();
boolean protectUpper = t == BlockData.class && upperCtx != null;
MantleChunk<Matter> chunk = getMantle().getChunk(x, z).use();
try {
chunk.iterate(t, blocks::set);
if (protectUpper) {
int chunkBlockX = x << 4;
int chunkBlockZ = z << 4;
int gap = getEngine().getDimension().getUpperDimensionGap();
int[] upperYs = new int[256];
for (int i = 0; i < 256; i++) {
int lx = i >> 4;
int lz = i & 15;
int worldX = chunkBlockX + lx;
int worldZ = chunkBlockZ + lz;
int he = (int) Math.round(getEngine().getComplex().getHeightStream().get((double) worldX, (double) worldZ));
int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ);
upperYs[i] = Math.max(rawUpper, he + gap);
}
chunk.iterate(t, (lx, y, lz, value) -> {
int colIdx = (lx << 4) | (lz & 15);
if (y < upperYs[colIdx]) {
blocks.set(lx, y, lz, value);
}
});
} else {
chunk.iterate(t, blocks::set);
}
} finally {
chunk.release();
}
@@ -150,6 +150,20 @@ public class IrisCaveCarver3D {
double thresholdPenalty,
IrisRange worldYRange,
int[] precomputedSurfaceHeights
) {
return carve(writer, chunkX, chunkZ, columnWeights, minWeight, thresholdPenalty, worldYRange, precomputedSurfaceHeights, null);
}
public int carve(
MantleWriter writer,
int chunkX,
int chunkZ,
double[] columnWeights,
double minWeight,
double thresholdPenalty,
IrisRange worldYRange,
int[] precomputedSurfaceHeights,
IrisRange overrideVerticalRange
) {
PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start();
try {
@@ -165,8 +179,9 @@ public class IrisCaveCarver3D {
double resolvedMinWeight = Math.max(0D, Math.min(1D, minWeight));
double resolvedThresholdPenalty = Math.max(0D, thresholdPenalty);
int worldHeight = writer.getMantle().getWorldHeight();
int minY = Math.max(0, (int) Math.floor(profile.getVerticalRange().getMin()));
int maxY = Math.min(worldHeight - 1, (int) Math.ceil(profile.getVerticalRange().getMax()));
IrisRange effectiveVerticalRange = overrideVerticalRange != null ? overrideVerticalRange : profile.getVerticalRange();
int minY = Math.max(0, (int) Math.floor(effectiveVerticalRange.getMin()));
int maxY = Math.min(worldHeight - 1, (int) Math.ceil(effectiveVerticalRange.getMax()));
if (worldYRange != null) {
int worldMinHeight = engine.getWorld().minHeight();
int rangeMinY = (int) Math.floor(worldYRange.getMin() - worldMinHeight);
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.mantle.components;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.mantle.ComponentFlag;
import art.arcane.iris.engine.mantle.EngineMantle;
@@ -91,6 +92,11 @@ public class MantleCarvingComponent extends IrisMantleComponent {
for (WeightedProfile weightedProfile : weightedProfiles) {
carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights);
}
UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext();
if (upperCtx != null && getDimension().isUpperDimensionCarving()) {
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights);
}
}
@ChunkCoordinates
@@ -99,6 +105,58 @@ public class MantleCarvingComponent extends IrisMantleComponent {
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, weightedProfile.worldYRange, chunkSurfaceHeights);
}
private void carveUpperTerrain(UpperDimensionContext upperCtx, List<WeightedProfile> normalProfiles, MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights) {
int chunkHeight = getEngineMantle().getEngine().getHeight();
int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight();
int gap = getDimension().getUpperDimensionGap();
int baseX = PowerOfTwoCoordinates.chunkToBlock(cx);
int baseZ = PowerOfTwoCoordinates.chunkToBlock(cz);
int minUpperSurfaceY = chunkHeight;
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
int worldX = baseX + localX;
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
int worldZ = baseZ + localZ;
int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ);
int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ);
int upperY = Math.max(rawUpper, lowerSurfaceHeights[columnIndex] + gap);
if (upperY < minUpperSurfaceY) {
minUpperSurfaceY = upperY;
}
}
}
if (minUpperSurfaceY >= chunkHeight - 2) {
return;
}
IrisRange upperYRange = new IrisRange(
minUpperSurfaceY + worldMinHeight,
chunkHeight - 1 + worldMinHeight
);
IrisRange fullVerticalRange = new IrisRange(0, chunkHeight);
int[] ceilingSurfaceHeights = new int[CHUNK_AREA];
Arrays.fill(ceilingSurfaceHeights, chunkHeight - 1);
for (WeightedProfile weightedProfile : normalProfiles) {
IrisRange constrainedRange;
if (weightedProfile.worldYRange != null) {
double min = Math.max(weightedProfile.worldYRange.getMin(), upperYRange.getMin());
double max = Math.min(weightedProfile.worldYRange.getMax(), upperYRange.getMax());
if (min >= max) {
continue;
}
constrainedRange = new IrisRange(min, max);
} else {
constrainedRange = upperYRange;
}
IrisCaveCarver3D carver = getCarver(weightedProfile.profile);
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY,
constrainedRange, ceilingSurfaceHeights, fullVerticalRange);
}
}
private List<WeightedProfile> resolveWeightedProfiles(int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState) {
BlendScratch blendScratch = BLEND_SCRATCH.get();
IrisCaveProfile[] profileField = blendScratch.profileField;
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.modifier;
import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.actuator.IrisDecorantActuator;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineAssignedModifier;
@@ -82,10 +83,22 @@ public class IrisCarveModifier extends EngineAssignedModifier<BlockData> {
MatterCavern[] boundaryCaverns = scratch.boundaryCaverns;
int[] surfaceHeights = scratch.surfaceHeights;
Map<String, IrisBiome> customBiomeCache = scratch.customBiomeCache;
UpperDimensionContext upperCtx = getEngine().getUpperContext();
boolean protectUpper = upperCtx != null && !getEngine().getDimension().isUpperDimensionCarving();
int[] upperSurfaceHeights = protectUpper ? scratch.getOrCreateUpperSurfaceHeights() : null;
int chunkBlockX = PowerOfTwoCoordinates.chunkToBlock(x);
int chunkBlockZ = PowerOfTwoCoordinates.chunkToBlock(z);
for (int columnIndex = 0; columnIndex < 256; columnIndex++) {
int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex);
int localZ = columnIndex & 15;
surfaceHeights[columnIndex] = context.getRoundedHeight(localX, localZ);
if (protectUpper) {
int worldX = localX + chunkBlockX;
int worldZ = localZ + chunkBlockZ;
int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ);
int gap = getEngine().getDimension().getUpperDimensionGap();
upperSurfaceHeights[columnIndex] = Math.max(rawUpper, surfaceHeights[columnIndex] + gap);
}
}
try {
@@ -102,6 +115,11 @@ public class IrisCarveModifier extends EngineAssignedModifier<BlockData> {
int rx = xx & 15;
int rz = zz & 15;
int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz);
if (upperSurfaceHeights != null && yy >= upperSurfaceHeights[columnIndex]) {
return;
}
BlockData current = output.getRaw(rx, yy, rz);
if (B.isFluid(current)) {
@@ -723,6 +741,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<BlockData> {
private final int[] surfaceHeights = new int[256];
private final PackedWallBuffer walls = new PackedWallBuffer(512);
private final Map<String, IrisBiome> customBiomeCache = new HashMap<>();
private int[] upperSurfaceHeights;
private CarveScratch() {
for (int index = 0; index < columnMasks.length; index++) {
@@ -731,6 +750,13 @@ public class IrisCarveModifier extends EngineAssignedModifier<BlockData> {
}
}
private int[] getOrCreateUpperSurfaceHeights() {
if (upperSurfaceHeights == null) {
upperSurfaceHeights = new int[256];
}
return upperSurfaceHeights;
}
private void reset() {
for (int index = 0; index < columnMasks.length; index++) {
columnMasks[index].clear();
@@ -177,6 +177,19 @@ public class IrisDimension extends IrisRegistrant {
private IrisRange dimensionHeight = new IrisRange(-64, 320);
@Desc("Define options for this dimension")
private IrisDimensionTypeOptions dimensionOptions = new IrisDimensionTypeOptions();
@Desc("When true, sets the dimension's ambient light to maximum, making all blocks fully lit regardless of light level.")
private boolean fullbright = false;
@RegistryListResource(IrisDimension.class)
@Desc("When set, generates the referenced dimension's terrain upside-down at the world ceiling, creating a nether-like canopy. Use the dimension's load key (e.g., 'overworld'). Set to 'none' to disable.")
private String upperDimension = "none";
@MinNumber(0)
@MaxNumber(256)
@Desc("Minimum air gap in blocks between the lower terrain surface and the upper terrain surface.")
private int upperDimensionGap = 32;
@Desc("When true, cave carving will cut through the upper dimension terrain. When false, the upper terrain is a solid untouched mass.")
private boolean upperDimensionCarving = false;
@Desc("When true, objects from the mantle (structures, trees, etc.) can be placed in the upper dimension terrain zone. When false, the upper terrain is protected from object placement.")
private boolean upperDimensionObjects = false;
@RegistryListResource(IrisBiome.class)
@Desc("Keep this either undefined or empty. Setting any biome name into this will force iris to only generate the specified biome. Great for testing.")
private String focus = "";
@@ -650,7 +663,15 @@ public class IrisDimension extends IrisRegistrant {
}
public IrisDimensionType getDimensionType() {
return new IrisDimensionType(getBaseDimension(), getDimensionOptions(), getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight());
IrisDimensionTypeOptions options = getDimensionOptions();
if (fullbright) {
options = options.copy().ambientLight(1.0f);
}
return new IrisDimensionType(getBaseDimension(), options, getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight());
}
public boolean hasUpperDimension() {
return upperDimension != null && !upperDimension.isEmpty() && !upperDimension.equalsIgnoreCase("none");
}
public void installDimensionType(IDataFixer fixer, KList<File> folders) {
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.studio.generators.BiomeBuffetGenerator;
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator;
import java.util.function.Consumer;
@@ -34,7 +35,7 @@ public enum StudioMode {
BIOME_BUFFET_18x18(i -> i.setStudioGenerator(new BiomeBuffetGenerator(i.getEngine(), 18))),
BIOME_BUFFET_36x36(i -> i.setStudioGenerator(new BiomeBuffetGenerator(i.getEngine(), 36))),
REGION_BUFFET(i -> i.setStudioGenerator(null)),
OBJECT_BUFFET(i -> i.setStudioGenerator(null)),
OBJECT_BUFFET(i -> i.setStudioGenerator(new ObjectStudioGenerator(i.getEngine()))),
;
@@ -778,9 +778,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}
private void computeStudioGenerator() {
if (!getEngine().getDimension().getStudioMode().equals(lastMode)) {
lastMode = getEngine().getDimension().getStudioMode();
getEngine().getDimension().getStudioMode().inject(this);
StudioMode desired = getEngine().getDimension().getStudioMode();
if (studio && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) {
desired = StudioMode.OBJECT_BUFFET;
}
if (!desired.equals(lastMode)) {
lastMode = desired;
desired.inject(this);
}
}
@@ -807,6 +811,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
@Override
public boolean shouldGenerateStructures() {
if (isStudio() && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) {
return false;
}
return IrisSettings.get().getGeneral().isAutoGenerateIntrinsicStructures();
}
@@ -0,0 +1,289 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.engine.platform.studio.generators;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.runtime.ObjectStudioActivation;
import art.arcane.iris.core.runtime.ObjectStudioLayout;
import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell;
import art.arcane.iris.core.service.ObjectStudioSaveService;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.WrongEngineBroException;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator;
import art.arcane.iris.util.common.data.VectorMap;
import art.arcane.iris.util.common.math.Vector3i;
import org.bukkit.Material;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.BlockVector;
import java.io.File;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
public class ObjectStudioGenerator extends EnginedStudioGenerator {
public static final int DEFAULT_PADDING = 2;
private static final BlockData FLOOR = Material.POLISHED_DEEPSLATE.createBlockData();
private static final BlockData FRAME = Material.SMOOTH_QUARTZ.createBlockData();
private static final BlockData MARKER = Material.END_ROD.createBlockData();
private static final Biome DEFAULT_BIOME = Biome.PLAINS;
private final int padding;
private final BlockData floor;
private final BlockData frame;
private final BlockData marker;
private final AtomicBoolean layoutBuilt = new AtomicBoolean(false);
private final Object layoutLock = new Object();
private final Map<String, IrisObject> objectCache = new ConcurrentHashMap<>();
private final Map<String, IrisData> packData = new ConcurrentHashMap<>();
private volatile ObjectStudioLayout layout;
public ObjectStudioGenerator(Engine engine) {
this(engine, DEFAULT_PADDING, FLOOR, FRAME, MARKER);
}
public ObjectStudioGenerator(Engine engine, int padding, BlockData floor, BlockData frame, BlockData marker) {
super(engine);
this.padding = padding;
this.floor = floor;
this.frame = frame;
this.marker = marker;
}
public ObjectStudioLayout getLayout() {
return layout;
}
public int getPadding() {
return padding;
}
@Override
public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException {
ensureLayout(engine);
int floorY = Math.max(engine.getMinHeight(), ObjectStudioLayout.FLOOR_Y);
for (int bx = 0; bx < 16; bx++) {
for (int bz = 0; bz < 16; bz++) {
tc.setBiome(bx, floorY, bz, DEFAULT_BIOME);
tc.setBlock(bx, floorY, bz, floor);
}
}
ObjectStudioLayout currentLayout = layout;
if (currentLayout == null) {
return;
}
int chunkWorldX = x << 4;
int chunkWorldZ = z << 4;
int chunkMaxX = chunkWorldX + 15;
int chunkMaxZ = chunkWorldZ + 15;
int minHeight = engine.getMinHeight();
int maxHeight = engine.getMaxHeight();
int plinthY = floorY + 1;
for (GridCell cell : currentLayout.cells()) {
int frameMinX = cell.originX() - 1;
int frameMaxX = cell.originX() + cell.w();
int frameMinZ = cell.originZ() - 1;
int frameMaxZ = cell.originZ() + cell.d();
if (frameMaxX < chunkWorldX || frameMinX > chunkMaxX) continue;
if (frameMaxZ < chunkWorldZ || frameMinZ > chunkMaxZ) continue;
paintFrame(cell, tc, plinthY, chunkWorldX, chunkWorldZ, minHeight, maxHeight);
IrisObject object = loadObject(cell);
if (object != null) {
placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight);
}
}
}
private void paintFrame(GridCell cell, TerrainChunk tc, int plinthY, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) {
int x0 = cell.originX() - 1;
int x1 = cell.originX() + cell.w();
int z0 = cell.originZ() - 1;
int z1 = cell.originZ() + cell.d();
int topY = Math.min(maxHeight - 1, plinthY + cell.h() + 1);
if (plinthY >= minHeight && plinthY < maxHeight) {
paintFrameRowX(tc, x0, x1, plinthY, z0, chunkWorldX, chunkWorldZ);
paintFrameRowX(tc, x0, x1, plinthY, z1, chunkWorldX, chunkWorldZ);
paintFrameRowZ(tc, x0, plinthY, z0, z1, chunkWorldX, chunkWorldZ);
paintFrameRowZ(tc, x1, plinthY, z0, z1, chunkWorldX, chunkWorldZ);
}
if (topY > plinthY && topY < maxHeight) {
paintFrameRowX(tc, x0, x1, topY, z0, chunkWorldX, chunkWorldZ);
paintFrameRowX(tc, x0, x1, topY, z1, chunkWorldX, chunkWorldZ);
paintFrameRowZ(tc, x0, topY, z0, z1, chunkWorldX, chunkWorldZ);
paintFrameRowZ(tc, x1, topY, z0, z1, chunkWorldX, chunkWorldZ);
}
int edgeLo = plinthY + 1;
int edgeHi = Math.min(topY - 1, maxHeight - 1);
if (edgeHi >= edgeLo) {
paintEdgePillar(tc, x0, edgeLo, edgeHi, z0, chunkWorldX, chunkWorldZ);
paintEdgePillar(tc, x1, edgeLo, edgeHi, z0, chunkWorldX, chunkWorldZ);
paintEdgePillar(tc, x0, edgeLo, edgeHi, z1, chunkWorldX, chunkWorldZ);
paintEdgePillar(tc, x1, edgeLo, edgeHi, z1, chunkWorldX, chunkWorldZ);
}
}
private void paintEdgePillar(TerrainChunk tc, int x, int yMin, int yMax, int z, int chunkWorldX, int chunkWorldZ) {
if (x < chunkWorldX || x > chunkWorldX + 15) return;
if (z < chunkWorldZ || z > chunkWorldZ + 15) return;
int localX = x - chunkWorldX;
int localZ = z - chunkWorldZ;
for (int y = yMin; y <= yMax; y++) {
tc.setBlock(localX, y, localZ, marker);
}
}
private void paintFrameRowX(TerrainChunk tc, int xMin, int xMax, int y, int z, int chunkWorldX, int chunkWorldZ) {
if (z < chunkWorldZ || z > chunkWorldZ + 15) return;
int lo = Math.max(xMin, chunkWorldX);
int hi = Math.min(xMax, chunkWorldX + 15);
for (int x = lo; x <= hi; x++) {
tc.setBlock(x - chunkWorldX, y, z - chunkWorldZ, frame);
}
}
private void paintFrameRowZ(TerrainChunk tc, int x, int y, int zMin, int zMax, int chunkWorldX, int chunkWorldZ) {
if (x < chunkWorldX || x > chunkWorldX + 15) return;
int lo = Math.max(zMin, chunkWorldZ);
int hi = Math.min(zMax, chunkWorldZ + 15);
for (int z = lo; z <= hi; z++) {
tc.setBlock(x - chunkWorldX, y, z - chunkWorldZ, frame);
}
}
private void placeSlice(IrisObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) {
VectorMap<BlockData> blocks = object.getBlocks();
if (blocks == null || blocks.isEmpty()) return;
Vector3i center = object.getCenter();
int centerX = center == null ? 0 : center.getBlockX();
int centerY = center == null ? 0 : center.getBlockY();
int centerZ = center == null ? 0 : center.getBlockZ();
int originX = cell.originX();
int originY = cell.originY();
int originZ = cell.originZ();
for (Map.Entry<BlockVector, BlockData> entry : blocks) {
BlockVector signed = entry.getKey();
int worldX = originX + signed.getBlockX() + centerX;
int worldY = originY + signed.getBlockY() + centerY;
int worldZ = originZ + signed.getBlockZ() + centerZ;
if (worldX < chunkWorldX || worldX > chunkWorldX + 15) continue;
if (worldZ < chunkWorldZ || worldZ > chunkWorldZ + 15) continue;
if (worldY < minHeight || worldY >= maxHeight) continue;
BlockData data = entry.getValue();
if (data == null) continue;
tc.setBlock(worldX - chunkWorldX, worldY, worldZ - chunkWorldZ, data);
}
}
private IrisObject loadObject(GridCell cell) {
String cacheKey = cell.pack() + "/" + cell.key();
IrisObject cached = objectCache.get(cacheKey);
if (cached != null) return cached;
IrisData data = packData.get(cell.pack());
if (data == null) return null;
IrisObject loaded = data.getObjectLoader().load(cell.key());
if (loaded != null) {
objectCache.put(cacheKey, loaded);
}
return loaded;
}
public Map<String, IrisData> getPackData() {
return packData;
}
private void ensureLayout(Engine engine) {
if (layoutBuilt.get()) return;
synchronized (layoutLock) {
if (layoutBuilt.get()) return;
Map<String, IrisData> sources = resolveSources(engine);
packData.putAll(sources);
File layoutFile = layoutFile(engine);
ObjectStudioLayout resumed = ObjectStudioLayout.load(layoutFile, sources, padding);
if (resumed != null) {
layout = resumed;
} else {
layout = ObjectStudioLayout.build(sources, padding);
layout.save(layoutFile);
}
layoutBuilt.set(true);
try {
ObjectStudioSaveService.get().register(engine, this);
} catch (Throwable e) {
Iris.reportError(e);
}
int cellCount = layout.cells().size();
BlockVector worldExtent = computeExtent(layout);
Iris.info("Object Studio layout built: %d cells from %d pack(s), extent %d x %d blocks",
cellCount, sources.size(), worldExtent.getBlockX(), worldExtent.getBlockZ());
}
}
private Map<String, IrisData> resolveSources(Engine engine) {
String packKey = engine.getDimension() == null ? null : engine.getDimension().getLoadKey();
Map<String, IrisData> registered = packKey == null ? null : ObjectStudioActivation.getSources(packKey);
if (registered != null && !registered.isEmpty()) {
return registered;
}
Map<String, IrisData> fallback = new LinkedHashMap<>();
IrisData data = engine.getData();
fallback.put(data.getDataFolder().getName(), data);
return fallback;
}
private File layoutFile(Engine engine) {
File worldFolder = engine.getTarget().getWorld().worldFolder();
return new File(new File(worldFolder, ".iris"), "object-studio-layout.json");
}
private static BlockVector computeExtent(ObjectStudioLayout layout) {
int maxX = 0;
int maxZ = 0;
for (GridCell cell : layout.cells()) {
maxX = Math.max(maxX, cell.originX() + cell.w());
maxZ = Math.max(maxZ, cell.originZ() + cell.d());
}
return new BlockVector(maxX, 0, maxZ);
}
}