diff --git a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchema.php b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchema.php index 6d280ecf7..f8894cfd2 100644 --- a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchema.php +++ b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchema.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\data\bedrock\block\upgrade; +use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaFlattenInfo as FlattenInfo; use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaValueRemap as ValueRemap; use pocketmine\nbt\tag\Tag; use function count; @@ -58,6 +59,12 @@ final class BlockStateUpgradeSchema{ */ public array $remappedPropertyValues = []; + /** + * @var FlattenInfo[] + * @phpstan-var array + */ + public array $flattenedProperties = []; + /** * @var BlockStateUpgradeSchemaBlockRemap[][] * @phpstan-var array> @@ -93,6 +100,7 @@ final class BlockStateUpgradeSchema{ $this->removedProperties, $this->renamedProperties, $this->remappedPropertyValues, + $this->flattenedProperties, $this->remappedStates, ] as $list){ if(count($list) !== 0){ diff --git a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaBlockRemap.php b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaBlockRemap.php index 611ad04e2..676afbaf4 100644 --- a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaBlockRemap.php +++ b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaBlockRemap.php @@ -40,7 +40,7 @@ final class BlockStateUpgradeSchemaBlockRemap{ */ public function __construct( public array $oldState, - public string|BlockStateUpgradeSchemaFlattenedName $newName, + public string|BlockStateUpgradeSchemaFlattenInfo $newName, public array $newState, public array $copiedState ){} @@ -48,8 +48,8 @@ final class BlockStateUpgradeSchemaBlockRemap{ public function equals(self $that) : bool{ $sameName = $this->newName === $that->newName || ( - $this->newName instanceof BlockStateUpgradeSchemaFlattenedName && - $that->newName instanceof BlockStateUpgradeSchemaFlattenedName && + $this->newName instanceof BlockStateUpgradeSchemaFlattenInfo && + $that->newName instanceof BlockStateUpgradeSchemaFlattenInfo && $this->newName->equals($that->newName) ); if(!$sameName){ diff --git a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaFlattenedName.php b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaFlattenInfo.php similarity index 74% rename from src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaFlattenedName.php rename to src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaFlattenInfo.php index 1c95dd9c7..4a14a1291 100644 --- a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaFlattenedName.php +++ b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaFlattenInfo.php @@ -23,20 +23,25 @@ declare(strict_types=1); namespace pocketmine\data\bedrock\block\upgrade; +use pocketmine\nbt\tag\ByteTag; +use pocketmine\nbt\tag\IntTag; +use pocketmine\nbt\tag\StringTag; use function ksort; use const SORT_STRING; -final class BlockStateUpgradeSchemaFlattenedName{ +final class BlockStateUpgradeSchemaFlattenInfo{ /** * @param string[] $flattenedValueRemaps * @phpstan-param array $flattenedValueRemaps + * @phpstan-param ?class-string $flattenedPropertyType */ public function __construct( public string $prefix, public string $flattenedProperty, public string $suffix, - public array $flattenedValueRemaps + public array $flattenedValueRemaps, + public ?string $flattenedPropertyType = null ){ ksort($this->flattenedValueRemaps, SORT_STRING); } @@ -45,6 +50,7 @@ final class BlockStateUpgradeSchemaFlattenedName{ return $this->prefix === $that->prefix && $this->flattenedProperty === $that->flattenedProperty && $this->suffix === $that->suffix && - $this->flattenedValueRemaps === $that->flattenedValueRemaps; + $this->flattenedValueRemaps === $that->flattenedValueRemaps && + $this->flattenedPropertyType === $that->flattenedPropertyType; } } diff --git a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaUtils.php b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaUtils.php index 832631490..08eba8978 100644 --- a/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaUtils.php +++ b/src/data/bedrock/block/upgrade/BlockStateUpgradeSchemaUtils.php @@ -25,7 +25,7 @@ namespace pocketmine\data\bedrock\block\upgrade; use pocketmine\data\bedrock\block\upgrade\model\BlockStateUpgradeSchemaModel; use pocketmine\data\bedrock\block\upgrade\model\BlockStateUpgradeSchemaModelBlockRemap; -use pocketmine\data\bedrock\block\upgrade\model\BlockStateUpgradeSchemaModelFlattenedName; +use pocketmine\data\bedrock\block\upgrade\model\BlockStateUpgradeSchemaModelFlattenInfo; use pocketmine\data\bedrock\block\upgrade\model\BlockStateUpgradeSchemaModelTag; use pocketmine\data\bedrock\block\upgrade\model\BlockStateUpgradeSchemaModelValueRemap; use pocketmine\nbt\tag\ByteTag; @@ -155,20 +155,24 @@ final class BlockStateUpgradeSchemaUtils{ } } + foreach(Utils::stringifyKeys($model->flattenedProperties ?? []) as $blockName => $flattenRule){ + $result->flattenedProperties[$blockName] = self::jsonModelToFlattenRule($flattenRule); + } + foreach(Utils::stringifyKeys($model->remappedStates ?? []) as $oldBlockName => $remaps){ foreach($remaps as $remap){ - if(isset($remap->newName) === isset($remap->newFlattenedName)){ + if(isset($remap->newName)){ + $remapName = $remap->newName; + }elseif(isset($remap->newFlattenedName)){ + $flattenRule = $remap->newFlattenedName; + $remapName = self::jsonModelToFlattenRule($flattenRule); + }else{ throw new \UnexpectedValueException("Expected exactly one of 'newName' or 'newFlattenedName' properties to be set"); } $result->remappedStates[$oldBlockName][] = new BlockStateUpgradeSchemaBlockRemap( array_map(fn(BlockStateUpgradeSchemaModelTag $tag) => self::jsonModelToTag($tag), $remap->oldState ?? []), - $remap->newName ?? new BlockStateUpgradeSchemaFlattenedName( - $remap->newFlattenedName->prefix, - $remap->newFlattenedName->flattenedProperty, - $remap->newFlattenedName->suffix, - $remap->newFlattenedName->flattenedValueRemaps ?? [], - ), + $remapName, array_map(fn(BlockStateUpgradeSchemaModelTag $tag) => self::jsonModelToTag($tag), $remap->newState ?? []), $remap->copiedState ?? [] ); @@ -254,6 +258,36 @@ final class BlockStateUpgradeSchemaUtils{ $model->remappedPropertyValues = $modelDedupMapping; } + private static function flattenRuleToJsonModel(BlockStateUpgradeSchemaFlattenInfo $flattenRule) : BlockStateUpgradeSchemaModelFlattenInfo{ + return new BlockStateUpgradeSchemaModelFlattenInfo( + $flattenRule->prefix, + $flattenRule->flattenedProperty, + $flattenRule->suffix, + $flattenRule->flattenedValueRemaps, + match($flattenRule->flattenedPropertyType){ + StringTag::class => null, //omit for TAG_String, as this is the common case + ByteTag::class => "byte", + IntTag::class => "int", + default => throw new \LogicException("Unexpected tag type " . $flattenRule->flattenedPropertyType . " in flattened property type") + } + ); + } + + private static function jsonModelToFlattenRule(BlockStateUpgradeSchemaModelFlattenInfo $flattenRule) : BlockStateUpgradeSchemaFlattenInfo{ + return new BlockStateUpgradeSchemaFlattenInfo( + $flattenRule->prefix, + $flattenRule->flattenedProperty, + $flattenRule->suffix, + $flattenRule->flattenedValueRemaps ?? [], + match ($flattenRule->flattenedPropertyType) { + "string", null => StringTag::class, + "int" => IntTag::class, + "byte" => ByteTag::class, + default => throw new \UnexpectedValueException("Unexpected flattened property type $flattenRule->flattenedPropertyType, expected 'string', 'int' or 'byte'") + } + ); + } + public static function toJsonModel(BlockStateUpgradeSchema $schema) : BlockStateUpgradeSchemaModel{ $result = new BlockStateUpgradeSchemaModel(); $result->maxVersionMajor = $schema->maxVersionMajor; @@ -292,19 +326,19 @@ final class BlockStateUpgradeSchemaUtils{ self::buildRemappedValuesIndex($schema, $result); + foreach(Utils::stringifyKeys($schema->flattenedProperties) as $blockName => $flattenRule){ + $result->flattenedProperties[$blockName] = self::flattenRuleToJsonModel($flattenRule); + } + if(isset($result->flattenedProperties)){ + ksort($result->flattenedProperties); + } + foreach(Utils::stringifyKeys($schema->remappedStates) as $oldBlockName => $remaps){ $keyedRemaps = []; foreach($remaps as $remap){ $modelRemap = new BlockStateUpgradeSchemaModelBlockRemap( array_map(fn(Tag $tag) => self::tagToJsonModel($tag), $remap->oldState), - is_string($remap->newName) ? - $remap->newName : - new BlockStateUpgradeSchemaModelFlattenedName( - $remap->newName->prefix, - $remap->newName->flattenedProperty, - $remap->newName->suffix, - $remap->newName->flattenedValueRemaps - ), + is_string($remap->newName) ? $remap->newName : self::flattenRuleToJsonModel($remap->newName), array_map(fn(Tag $tag) => self::tagToJsonModel($tag), $remap->newState), $remap->copiedState ); diff --git a/src/data/bedrock/block/upgrade/BlockStateUpgrader.php b/src/data/bedrock/block/upgrade/BlockStateUpgrader.php index 4a305d8bc..2dce762b8 100644 --- a/src/data/bedrock/block/upgrade/BlockStateUpgrader.php +++ b/src/data/bedrock/block/upgrade/BlockStateUpgrader.php @@ -24,10 +24,14 @@ declare(strict_types=1); namespace pocketmine\data\bedrock\block\upgrade; use pocketmine\data\bedrock\block\BlockStateData; +use pocketmine\nbt\tag\ByteTag; +use pocketmine\nbt\tag\IntTag; use pocketmine\nbt\tag\StringTag; use pocketmine\nbt\tag\Tag; +use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\Utils; use function count; +use function get_class; use function is_string; use function ksort; use function max; @@ -79,6 +83,8 @@ final class BlockStateUpgrader{ * version doesn't tell us which of the schemas have already been applied. * If there's only one schema for a version (the norm), we can safely assume it's already been applied if * the version is the same, and skip over it. + * TODO: this causes issues when testing isolated schemas since there will only be one schema for a version. + * The second check should be disabled for that case. */ if($version > $resultVersion || (count($schemaList) === 1 && $version === $resultVersion)){ continue; @@ -104,10 +110,21 @@ final class BlockStateUpgrader{ } $oldName = $blockStateData->getName(); - $newName = $schema->renamedIds[$oldName] ?? null; + $states = $blockStateData->getStates(); + + if(isset($schema->renamedIds[$oldName]) && isset($schema->flattenedProperties[$oldName])){ + //TODO: this probably ought to be validated when the schema is constructed + throw new AssumptionFailedError("Both renamedIds and flattenedProperties are set for the same block ID \"$oldName\" - don't know what to do"); + } + if(isset($schema->renamedIds[$oldName])){ + $newName = $schema->renamedIds[$oldName] ?? null; + }elseif(isset($schema->flattenedProperties[$oldName])){ + [$newName, $states] = $this->applyPropertyFlattened($schema->flattenedProperties[$oldName], $oldName, $states); + }else{ + $newName = null; + } $stateChanges = 0; - $states = $blockStateData->getStates(); $states = $this->applyPropertyAdded($schema, $oldName, $states, $stateChanges); $states = $this->applyPropertyRemoved($schema, $oldName, $states, $stateChanges); @@ -140,15 +157,8 @@ final class BlockStateUpgrader{ if(is_string($remap->newName)){ $newName = $remap->newName; }else{ - $flattenedValue = $oldState[$remap->newName->flattenedProperty] ?? null; - if($flattenedValue instanceof StringTag){ - $embedValue = $remap->newName->flattenedValueRemaps[$flattenedValue->getValue()] ?? $flattenedValue->getValue(); - $newName = sprintf("%s%s%s", $remap->newName->prefix, $embedValue, $remap->newName->suffix); - unset($oldState[$remap->newName->flattenedProperty]); - }else{ - //flattened property is not a TAG_String, so this transformation is not applicable - continue; - } + //discard flatten modifications to state - the remap newState and copiedState will take care of it + [$newName, ] = $this->applyPropertyFlattened($remap->newName, $oldName, $oldState); } $newState = $remap->newState; @@ -266,4 +276,32 @@ final class BlockStateUpgrader{ return $states; } + + /** + * @param Tag[] $states + * @phpstan-param array $states + * + * @return (string|Tag[])[] + * @phpstan-return array{0: string, 1: array} + */ + private function applyPropertyFlattened(BlockStateUpgradeSchemaFlattenInfo $flattenInfo, string $oldName, array $states) : array{ + $flattenedValue = $states[$flattenInfo->flattenedProperty] ?? null; + $expectedType = $flattenInfo->flattenedPropertyType; + if(!$flattenedValue instanceof $expectedType){ + //flattened property is not of the expected type, so this transformation is not applicable + return [$oldName, $states]; + } + $embedKey = match(get_class($flattenedValue)){ + StringTag::class => $flattenedValue->getValue(), + ByteTag::class => (string) $flattenedValue->getValue(), + IntTag::class => (string) $flattenedValue->getValue(), + //flattenedPropertyType is always one of these three types, but PHPStan doesn't know that + default => throw new AssumptionFailedError("flattenedPropertyType should be one of these three types, but have " . get_class($flattenedValue)), + }; + $embedValue = $flattenInfo->flattenedValueRemaps[$embedKey] ?? $embedKey; + $newName = sprintf("%s%s%s", $flattenInfo->prefix, $embedValue, $flattenInfo->suffix); + unset($states[$flattenInfo->flattenedProperty]); + + return [$newName, $states]; + } } diff --git a/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModel.php b/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModel.php index 1a4a14c87..7d91438e4 100644 --- a/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModel.php +++ b/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModel.php @@ -75,6 +75,12 @@ final class BlockStateUpgradeSchemaModel implements \JsonSerializable{ */ public array $remappedPropertyValuesIndex; + /** + * @var BlockStateUpgradeSchemaModelFlattenInfo[] + * @phpstan-var array + */ + public array $flattenedProperties; + /** * @var BlockStateUpgradeSchemaModelBlockRemap[][] * @phpstan-var array> diff --git a/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelBlockRemap.php b/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelBlockRemap.php index 0f518479e..6accf1f02 100644 --- a/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelBlockRemap.php +++ b/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelBlockRemap.php @@ -43,7 +43,7 @@ final class BlockStateUpgradeSchemaModelBlockRemap{ * Either this or newName must be present * Due to technical limitations of jsonmapper, we can't use a union type here */ - public BlockStateUpgradeSchemaModelFlattenedName $newFlattenedName; + public BlockStateUpgradeSchemaModelFlattenInfo $newFlattenedName; /** * @var BlockStateUpgradeSchemaModelTag[]|null @@ -67,9 +67,9 @@ final class BlockStateUpgradeSchemaModelBlockRemap{ * @phpstan-param array $newState * @phpstan-param list $copiedState */ - public function __construct(array $oldState, string|BlockStateUpgradeSchemaModelFlattenedName $newNameRule, array $newState, array $copiedState){ + public function __construct(array $oldState, string|BlockStateUpgradeSchemaModelFlattenInfo $newNameRule, array $newState, array $copiedState){ $this->oldState = count($oldState) === 0 ? null : $oldState; - if($newNameRule instanceof BlockStateUpgradeSchemaModelFlattenedName){ + if($newNameRule instanceof BlockStateUpgradeSchemaModelFlattenInfo){ $this->newFlattenedName = $newNameRule; }else{ $this->newName = $newNameRule; diff --git a/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelFlattenedName.php b/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelFlattenInfo.php similarity index 81% rename from src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelFlattenedName.php rename to src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelFlattenInfo.php index 001192f47..6da590287 100644 --- a/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelFlattenedName.php +++ b/src/data/bedrock/block/upgrade/model/BlockStateUpgradeSchemaModelFlattenInfo.php @@ -25,12 +25,13 @@ namespace pocketmine\data\bedrock\block\upgrade\model; use function count; -final class BlockStateUpgradeSchemaModelFlattenedName implements \JsonSerializable{ +final class BlockStateUpgradeSchemaModelFlattenInfo implements \JsonSerializable{ /** @required */ public string $prefix; /** @required */ public string $flattenedProperty; + public ?string $flattenedPropertyType = null; /** @required */ public string $suffix; /** @@ -43,11 +44,12 @@ final class BlockStateUpgradeSchemaModelFlattenedName implements \JsonSerializab * @param string[] $flattenedValueRemaps * @phpstan-param array $flattenedValueRemaps */ - public function __construct(string $prefix, string $flattenedProperty, string $suffix, array $flattenedValueRemaps){ + public function __construct(string $prefix, string $flattenedProperty, string $suffix, array $flattenedValueRemaps, ?string $flattenedPropertyType = null){ $this->prefix = $prefix; $this->flattenedProperty = $flattenedProperty; $this->suffix = $suffix; $this->flattenedValueRemaps = $flattenedValueRemaps; + $this->flattenedPropertyType = $flattenedPropertyType; } /** @@ -58,6 +60,9 @@ final class BlockStateUpgradeSchemaModelFlattenedName implements \JsonSerializab if(count($this->flattenedValueRemaps) === 0){ unset($result["flattenedValueRemaps"]); } + if($this->flattenedPropertyType === null){ + unset($result["flattenedPropertyType"]); + } return $result; } } diff --git a/tests/phpunit/data/bedrock/block/upgrade/BlockStateUpgraderTest.php b/tests/phpunit/data/bedrock/block/upgrade/BlockStateUpgraderTest.php index 4d4d321ec..91afd8ed9 100644 --- a/tests/phpunit/data/bedrock/block/upgrade/BlockStateUpgraderTest.php +++ b/tests/phpunit/data/bedrock/block/upgrade/BlockStateUpgraderTest.php @@ -24,8 +24,10 @@ declare(strict_types=1); namespace pocketmine\data\bedrock\block\upgrade; use PHPUnit\Framework\TestCase; +use pocketmine\block\Block; use pocketmine\data\bedrock\block\BlockStateData; use pocketmine\nbt\tag\IntTag; +use pocketmine\nbt\tag\StringTag; use const PHP_INT_MAX; class BlockStateUpgraderTest extends TestCase{ @@ -210,6 +212,23 @@ class BlockStateUpgraderTest extends TestCase{ self::assertSame($upgradedStateData->getState(self::TEST_PROPERTY_2)?->getValue(), $valueAfter); } + public function testFlattenProperty() : void{ + $schema = $this->getNewSchema(); + $schema->flattenedProperties[self::TEST_BLOCK] = new BlockStateUpgradeSchemaFlattenInfo( + "minecraft:", + "test", + "_suffix", + [], + StringTag::class + ); + + $stateData = new BlockStateData(self::TEST_BLOCK, ["test" => new StringTag("value1")], 0); + $upgradedStateData = $this->upgrade($stateData, fn() => $stateData); + + self::assertSame("minecraft:value1_suffix", $upgradedStateData->getName()); + self::assertEmpty($upgradedStateData->getStates()); + } + /** * @phpstan-return \Generator */ diff --git a/tools/generate-blockstate-upgrade-schema.php b/tools/blockstate-upgrade-schema-utils.php similarity index 59% rename from tools/generate-blockstate-upgrade-schema.php rename to tools/blockstate-upgrade-schema-utils.php index 741098f7a..b7a9a4169 100644 --- a/tools/generate-blockstate-upgrade-schema.php +++ b/tools/blockstate-upgrade-schema-utils.php @@ -21,36 +21,49 @@ declare(strict_types=1); -namespace pocketmine\tools\generate_blockstate_upgrade_schema; +namespace pocketmine\tools\blockstate_upgrade_schema_utils; use pocketmine\data\bedrock\block\BlockStateData; +use pocketmine\data\bedrock\block\upgrade\BlockStateUpgrader; use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchema; use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaBlockRemap; -use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaFlattenedName; +use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaFlattenInfo; use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaUtils; use pocketmine\data\bedrock\block\upgrade\BlockStateUpgradeSchemaValueRemap; use pocketmine\nbt\LittleEndianNbtSerializer; +use pocketmine\nbt\tag\ByteTag; +use pocketmine\nbt\tag\IntTag; use pocketmine\nbt\tag\StringTag; use pocketmine\nbt\tag\Tag; use pocketmine\nbt\TreeRoot; +use pocketmine\network\mcpe\convert\BlockStateDictionary; use pocketmine\network\mcpe\protocol\serializer\NetworkNbtSerializer; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\Filesystem; use pocketmine\utils\Utils; +use Symfony\Component\Filesystem\Path; use function array_key_first; use function array_key_last; use function array_keys; use function array_map; use function array_shift; +use function array_unique; use function array_values; use function count; use function dirname; +use function file_exists; use function file_put_contents; use function fwrite; +use function get_class; +use function get_debug_type; use function implode; +use function is_dir; +use function is_numeric; use function json_encode; use function ksort; use function min; +use function preg_match; +use function scandir; use function sort; use function strlen; use function strrev; @@ -83,18 +96,18 @@ function encodeProperty(Tag $tag) : string{ } /** + * @param TreeRoot[] $oldNewStateList + * @phpstan-param list $oldNewStateList + * * @return BlockStateMapping[][] * @phpstan-return array> */ -function loadUpgradeTable(string $file, bool $reverse) : array{ - $contents = Filesystem::fileGetContents($file); - $data = (new NetworkNbtSerializer())->readMultiple($contents); - +function buildUpgradeTableFromData(array $oldNewStateList, bool $reverse) : array{ $result = []; - for($i = 0; isset($data[$i]); $i += 2){ - $oldTag = $data[$i]->mustGetCompoundTag(); - $newTag = $data[$i + 1]->mustGetCompoundTag(); + for($i = 0; isset($oldNewStateList[$i]); $i += 2){ + $oldTag = $oldNewStateList[$i]->mustGetCompoundTag(); + $newTag = $oldNewStateList[$i + 1]->mustGetCompoundTag(); $old = BlockStateData::fromNbt($reverse ? $newTag : $oldTag); $new = BlockStateData::fromNbt($reverse ? $oldTag : $newTag); @@ -107,6 +120,17 @@ function loadUpgradeTable(string $file, bool $reverse) : array{ return $result; } +/** + * @return BlockStateMapping[][] + * @phpstan-return array> + */ +function loadUpgradeTableFromFile(string $file, bool $reverse) : array{ + $contents = Filesystem::fileGetContents($file); + $data = (new NetworkNbtSerializer())->readMultiple($contents); + + return buildUpgradeTableFromData($data, $reverse); +} + /** * @param BlockStateData[] $states * @phpstan-param array $states @@ -159,6 +183,11 @@ function processStateGroup(string $oldName, array $upgradeTable, BlockStateUpgra $removedProperties = []; $renamedProperties = []; + $uniqueNewIds = []; + foreach($upgradeTable as $pair){ + $uniqueNewIds[$pair->new->getName()] = $pair->new->getName(); + } + foreach(Utils::stringifyKeys($newProperties) as $newPropertyName => $newPropertyValues){ if(count($newPropertyValues) === 1){ $newPropertyValue = $newPropertyValues[array_key_first($newPropertyValues)]; @@ -254,6 +283,45 @@ function processStateGroup(string $oldName, array $upgradeTable, BlockStateUpgra } } + if(count($uniqueNewIds) > 1){ + //detect possible flattening + $flattenedProperty = null; + $flattenedPropertyType = null; + $flattenedPropertyMap = []; + foreach($removedProperties as $removedProperty){ + $valueMap = []; + foreach($upgradeTable as $pair){ + $oldValue = $pair->old->getState($removedProperty); + if($oldValue === null){ + throw new AssumptionFailedError("We already checked that all states had consistent old properties"); + } + if(!checkFlattenPropertySuitability($oldValue, $flattenedPropertyType, $pair->new->getName(), $valueMap)){ + continue 2; + } + } + + if($flattenedProperty !== null){ + //found multiple candidates for flattening - fallback to remappedStates + return false; + } + //we found a suitable candidate + $flattenedProperty = $removedProperty; + $flattenedPropertyMap = $valueMap; + break; + } + + if($flattenedProperty === null){ + //can't figure out how the new IDs are related to the old states - fallback to remappedStates + return false; + } + if($flattenedPropertyType === null){ + throw new AssumptionFailedError("This should never happen at this point"); + } + + $result->flattenedProperties[$oldName] = buildFlattenPropertyRule($flattenedPropertyMap, $flattenedProperty, $flattenedPropertyType); + unset($removedProperties[$flattenedProperty]); + } + //finally, write the results to the schema if(count($remappedPropertyValues) !== 0){ @@ -308,43 +376,100 @@ function findCommonSuffix(array $strings) : string{ return strrev(findCommonPrefix($reversed)); } +/** + * @param string[] $valueToIdMap + * @phpstan-param ?class-string $expectedType + * @phpstan-param-out class-string $expectedType + * @phpstan-param array $valueToIdMap + * @phpstan-param-out array $valueToIdMap + */ +function checkFlattenPropertySuitability(Tag $oldValue, ?string &$expectedType, string $actualNewId, array &$valueToIdMap) : bool{ + //TODO: lots of similar logic to the remappedStates builder below + if(!$oldValue instanceof ByteTag && !$oldValue instanceof IntTag && !$oldValue instanceof StringTag){ + //unknown property type - bad candidate for flattening + return false; + } + if($expectedType === null){ + $expectedType = get_class($oldValue); + }elseif(!$oldValue instanceof $expectedType){ + //property type mismatch - bad candidate for flattening + return false; + } + + $rawValue = (string) $oldValue->getValue(); + $existingNewId = $valueToIdMap[$rawValue] ?? null; + if($existingNewId !== null && $existingNewId !== $actualNewId){ + //this property value is associated with multiple new IDs - bad candidate for flattening + return false; + } + $valueToIdMap[$rawValue] = $actualNewId; + + return true; +} + +/** + * @param string[] $valueToId + * @phpstan-param array $valueToId + * @phpstan-param class-string $propertyType + */ +function buildFlattenPropertyRule(array $valueToId, string $propertyName, string $propertyType) : BlockStateUpgradeSchemaFlattenInfo{ + $ids = array_values($valueToId); + + //TODO: this is a bit too enthusiastic. For example, when flattening the old "stone", it will see that + //"granite", "andesite", "stone" etc all have "e" as a common suffix, which works, but looks a bit daft. + //This also causes more remaps to be generated than necessary, since some of the values are already + //contained in the new ID. + $idPrefix = findCommonPrefix($ids); + $idSuffix = findCommonSuffix($ids); + if(strlen($idSuffix) < 2){ + $idSuffix = ""; + } + + $valueMap = []; + foreach(Utils::stringifyKeys($valueToId) as $value => $newId){ + $newValue = substr($newId, strlen($idPrefix), $idSuffix !== "" ? -strlen($idSuffix) : null); + if($newValue !== $value){ + $valueMap[$value] = $newValue; + } + } + + $allNumeric = true; + if(count($valueMap) > 0){ + foreach(Utils::stringifyKeys($valueMap) as $value => $newValue){ + if(!is_numeric($value)){ + $allNumeric = false; + break; + } + } + if($allNumeric){ + //add a dummy key to force the JSON to be an object and not a list + $valueMap["dummy"] = "map_not_list"; + } + } + + return new BlockStateUpgradeSchemaFlattenInfo( + $idPrefix, + $propertyName, + $idSuffix, + $valueMap, + $propertyType, + ); +} + /** * @param string[][][] $candidateFlattenedValues * @phpstan-param array>> $candidateFlattenedValues + * @param string[] $candidateFlattenPropertyTypes + * @phpstan-param array> $candidateFlattenPropertyTypes * - * @return BlockStateUpgradeSchemaFlattenedName[][] - * @phpstan-return array> + * @return BlockStateUpgradeSchemaFlattenInfo[][] + * @phpstan-return array> */ -function buildFlattenPropertyRules(array $candidateFlattenedValues) : array{ +function buildFlattenPropertyRules(array $candidateFlattenedValues, array $candidateFlattenPropertyTypes) : array{ $flattenPropertyRules = []; foreach(Utils::stringifyKeys($candidateFlattenedValues) as $propertyName => $filters){ foreach(Utils::stringifyKeys($filters) as $filter => $valueToId){ - $ids = array_values($valueToId); - - //TODO: this is a bit too enthusiastic. For example, when flattening the old "stone", it will see that - //"granite", "andesite", "stone" etc all have "e" as a common suffix, which works, but looks a bit daft. - //This also causes more remaps to be generated than necessary, since some of the values are already - //contained in the new ID. - $idPrefix = findCommonPrefix($ids); - $idSuffix = findCommonSuffix($ids); - if(strlen($idSuffix) < 2){ - $idSuffix = ""; - } - - $valueMap = []; - foreach(Utils::stringifyKeys($valueToId) as $value => $newId){ - $newValue = substr($newId, strlen($idPrefix), $idSuffix !== "" ? -strlen($idSuffix) : null); - if($newValue !== $value){ - $valueMap[$value] = $newValue; - } - } - - $flattenPropertyRules[$propertyName][$filter] = new BlockStateUpgradeSchemaFlattenedName( - $idPrefix, - $propertyName, - $idSuffix, - $valueMap - ); + $flattenPropertyRules[$propertyName][$filter] = buildFlattenPropertyRule($valueToId, $propertyName, $candidateFlattenPropertyTypes[$propertyName]); } } ksort($flattenPropertyRules, SORT_STRING); @@ -406,56 +531,54 @@ function processRemappedStates(array $upgradeTable) : array{ $notFlattenedProperties = []; $candidateFlattenedValues = []; + $candidateFlattenedPropertyTypes = []; foreach($upgradeTable as $pair){ foreach(Utils::stringifyKeys($pair->old->getStates()) as $propertyName => $propertyValue){ if(isset($notFlattenedProperties[$propertyName])){ continue; } - if(!$propertyValue instanceof StringTag){ - $notFlattenedProperties[$propertyName] = true; - continue; - } - $rawValue = $propertyValue->getValue(); - if($rawValue === ""){ - $notFlattenedProperties[$propertyName] = true; - continue; - } $filter = $pair->old->getStates(); foreach($unchangedStatesByNewName[$pair->new->getName()] as $unchangedPropertyName){ + if($unchangedPropertyName === $propertyName){ + $notFlattenedProperties[$propertyName] = true; + continue 2; + } unset($filter[$unchangedPropertyName]); } unset($filter[$propertyName]); $rawFilter = encodeOrderedProperties($filter); - if(isset($candidateFlattenedValues[$propertyName][$rawFilter])){ - $valuesToIds = $candidateFlattenedValues[$propertyName][$rawFilter]; - $existingNewId = $valuesToIds[$rawValue] ?? null; - if($existingNewId !== null && $existingNewId !== $pair->new->getName()){ - //this old value is associated with multiple new IDs - bad candidate for flattening - $notFlattenedProperties[$propertyName] = true; - continue; - } - foreach(Utils::stringifyKeys($valuesToIds) as $otherRawValue => $otherNewId){ - if($otherRawValue === $rawValue){ - continue; - } - if($otherNewId === $pair->new->getName()){ - //this old value maps to the same new ID as another old value - bad candidate for flattening - $notFlattenedProperties[$propertyName] = true; - continue 2; - } - } + $candidateFlattenedValues[$propertyName][$rawFilter] ??= []; + $expectedType = $candidateFlattenedPropertyTypes[$propertyName] ?? null; + if(!checkFlattenPropertySuitability($propertyValue, $expectedType, $pair->new->getName(), $candidateFlattenedValues[$propertyName][$rawFilter])){ + $notFlattenedProperties[$propertyName] = true; + continue; + } + $candidateFlattenedPropertyTypes[$propertyName] = $expectedType; + } + } + foreach(Utils::stringifyKeys($candidateFlattenedValues) as $propertyName => $filters){ + foreach($filters as $valuesToIds){ + if(count(array_unique($valuesToIds)) === 1){ + //this property doesn't influence the new ID + $notFlattenedProperties[$propertyName] = true; + continue 2; } - $candidateFlattenedValues[$propertyName][$rawFilter][$rawValue] = $pair->new->getName(); } } foreach(Utils::stringifyKeys($notFlattenedProperties) as $propertyName => $_){ unset($candidateFlattenedValues[$propertyName]); } - $flattenedProperties = buildFlattenPropertyRules($candidateFlattenedValues); + $flattenedProperties = buildFlattenPropertyRules($candidateFlattenedValues, $candidateFlattenedPropertyTypes); $flattenProperty = array_key_first($flattenedProperties); + //Properties with fewer rules take up less space for the same result + foreach(Utils::stringifyKeys($flattenedProperties) as $propertyName => $rules){ + if(count($rules) < count($flattenedProperties[$flattenProperty])){ + $flattenProperty = $propertyName; + } + } $list = []; @@ -475,8 +598,8 @@ function processRemappedStates(array $upgradeTable) : array{ ksort($cleanedNewState); if($flattenProperty !== null){ $flattenedValue = $cleanedOldState[$flattenProperty] ?? null; - if(!$flattenedValue instanceof StringTag){ - throw new AssumptionFailedError("This should always be a TAG_String ($newName $flattenProperty)"); + if(!$flattenedValue instanceof StringTag && !$flattenedValue instanceof IntTag && !$flattenedValue instanceof ByteTag){ + throw new AssumptionFailedError("Non-flattenable type of tag ($newName $flattenProperty) but have " . get_debug_type($flattenedValue)); } unset($cleanedOldState[$flattenProperty]); } @@ -583,10 +706,15 @@ function generateBlockStateUpgradeSchema(array $upgradeTable) : BlockStateUpgrad throw new \RuntimeException("States with the same ID should be fully consistent"); } }else{ - //block mapped to multiple different new IDs; we can't guess these, so we just do a plain old remap - //even if some of the states stay under the same ID, the compression techniques used by this function - //implicitly rely on knowing the full set of old states and their new transformations - $result->remappedStates[$oldName] = processRemappedStates($blockStateMappings); + //try processing this as a regular state group first + //if a property was flattened into the ID, the remaining states will normally be consistent + //if not we fall back to remap states and state filters + if(!processStateGroup($oldName, $blockStateMappings, $result)){ + //block mapped to multiple different new IDs; we can't guess these, so we just do a plain old remap + //even if some of the states stay under the same ID, the compression techniques used by this function + //implicitly rely on knowing the full set of old states and their new transformations + $result->remappedStates[$oldName] = processRemappedStates($blockStateMappings); + } } } @@ -594,18 +722,42 @@ function generateBlockStateUpgradeSchema(array $upgradeTable) : BlockStateUpgrad } /** - * @param string[] $argv + * @param BlockStateMapping[][] $upgradeTable + * @phpstan-param array> $upgradeTable */ -function main(array $argv) : int{ - if(count($argv) !== 3){ - fwrite(STDERR, "Required arguments: input file path, output file path\n"); - return 1; +function testBlockStateUpgradeSchema(array $upgradeTable, BlockStateUpgradeSchema $schema) : bool{ + //TODO: HACK! + //the upgrader won't apply the schema if it's the same version and there's only one schema with a matching version + //ID (for performance reasons), which is a problem for testing isolated schemas + //add a dummy schema to bypass this optimization + $dummySchema = new BlockStateUpgradeSchema($schema->maxVersionMajor, $schema->maxVersionMinor, $schema->maxVersionPatch, $schema->maxVersionRevision, $schema->getSchemaId() + 1); + $upgrader = new BlockStateUpgrader([$schema, $dummySchema]); + + foreach($upgradeTable as $mappingsByOldName){ + foreach($mappingsByOldName as $mapping){ + $expectedNewState = $mapping->new; + + $actualNewState = $upgrader->upgrade($mapping->old); + + if(!$expectedNewState->equals($actualNewState)){ + \GlobalLogger::get()->error("Expected: " . $expectedNewState->toNbt()); + \GlobalLogger::get()->error("Actual: " . $actualNewState->toNbt()); + return false; + } + } } - $input = $argv[1]; - $output = $argv[2]; + return true; +} - $table = loadUpgradeTable($input, false); +/** + * @param string[] $argv + */ +function cmdGenerate(array $argv) : int{ + $upgradeTableFile = $argv[2]; + $schemaFile = $argv[3]; + + $table = loadUpgradeTableFromFile($upgradeTableFile, false); ksort($table, SORT_STRING); @@ -614,13 +766,148 @@ function main(array $argv) : int{ \GlobalLogger::get()->warning("All states appear to be the same! No schema generated."); return 0; } + + if(!testBlockStateUpgradeSchema($table, $diff)){ + \GlobalLogger::get()->error("Generated schema does not produce the results expected by $upgradeTableFile"); + \GlobalLogger::get()->error("This is probably a bug in the schema generation code. Please report this to the developers."); + return 1; + } + file_put_contents( - $output, + $schemaFile, json_encode(BlockStateUpgradeSchemaUtils::toJsonModel($diff), JSON_PRETTY_PRINT) . "\n" ); - \GlobalLogger::get()->info("Schema file $output generated successfully."); + \GlobalLogger::get()->info("Schema file $schemaFile generated successfully."); + return 0; +} + +/** + * @param string[] $argv + */ +function cmdTest(array $argv) : int{ + $upgradeTableFile = $argv[2]; + $schemaFile = $argv[3]; + + $table = loadUpgradeTableFromFile($upgradeTableFile, false); + + ksort($table, SORT_STRING); + + $schema = BlockStateUpgradeSchemaUtils::loadSchemaFromString(Filesystem::fileGetContents($schemaFile), 0); + if(!testBlockStateUpgradeSchema($table, $schema)){ + \GlobalLogger::get()->error("Schema $schemaFile does not produce the results predicted by $upgradeTableFile"); + return 1; + } + \GlobalLogger::get()->info("Schema $schemaFile is valid according to $upgradeTableFile"); return 0; } +/** + * @param string[] $argv + */ +function cmdUpdate(array $argv) : int{ + [, , $oldSchemaFile, $oldPaletteFile, $newSchemaFile] = $argv; + + $palette = BlockStateDictionary::loadPaletteFromString(Filesystem::fileGetContents($oldPaletteFile)); + $schema = BlockStateUpgradeSchemaUtils::loadSchemaFromString(Filesystem::fileGetContents($oldSchemaFile), 0); + //TODO: HACK! + //the upgrader won't apply the schema if it's the same version and there's only one schema with a matching version + //ID (for performance reasons), which is a problem for testing isolated schemas + //add a dummy schema to bypass this optimization + $dummySchema = new BlockStateUpgradeSchema($schema->maxVersionMajor, $schema->maxVersionMinor, $schema->maxVersionPatch, $schema->maxVersionRevision, $schema->getSchemaId() + 1); + $upgrader = new BlockStateUpgrader([$schema, $dummySchema]); + + $tags = []; + foreach($palette as $stateData){ + $tags[] = new TreeRoot($stateData->toNbt()); + $tags[] = new TreeRoot($upgrader->upgrade($stateData)->toNbt()); + } + + $upgradeTable = buildUpgradeTableFromData($tags, false); + $newSchema = generateBlockStateUpgradeSchema($upgradeTable); + + if(!testBlockStateUpgradeSchema($upgradeTable, $newSchema)){ + \GlobalLogger::get()->error("Updated schema does not produce the expected results!"); + \GlobalLogger::get()->error("This is probably a bug in the schema generation code. Please report this to the developers."); + return 1; + } + + file_put_contents( + $newSchemaFile, + json_encode(BlockStateUpgradeSchemaUtils::toJsonModel($newSchema), JSON_PRETTY_PRINT) . "\n" + ); + \GlobalLogger::get()->info("Schema file $newSchemaFile updated to new format (from $oldSchemaFile) successfully."); + return 0; +} + +/** + * @param string[] $argv + */ +function cmdUpdateAll(array $argv) : int{ + $oldPaletteFilenames = [ + '1.9.0' => '1.09.0', + '1.19.50' => '1.19.50.23_beta', + '1.19.60' => '1.19.60.26_beta', + '1.19.70' => '1.19.70.26_beta', + '1.19.80' => '1.19.80.24_beta', + ]; + $schemaDir = $argv[2]; + $paletteArchiveDir = $argv[3]; + + $schemaFileNames = scandir($schemaDir); + if($schemaFileNames === false){ + \GlobalLogger::get()->error("Failed to read schema directory $schemaDir"); + return 1; + } + foreach($schemaFileNames as $file){ + $schemaFile = Path::join($schemaDir, $file); + if(!file_exists($schemaFile) || is_dir($schemaFile)){ + continue; + } + + if(preg_match('/^\d{4}_(.+?)_to_(.+?).json/', $file, $matches) !== 1){ + continue; + } + $oldPaletteFile = Path::join($paletteArchiveDir, ($oldPaletteFilenames[$matches[1]] ?? $matches[1]) . '.nbt'); + + //a bit clunky but it avoids having to make yet another function + //TODO: perhaps in the future we should write the result to a tmpfile until all schemas are updated, + //and then copy the results into place at the end + if(cmdUpdate([$argv[0], "update", $schemaFile, $oldPaletteFile, $schemaFile]) !== 0){ + return 1; + } + } + + \GlobalLogger::get()->info("All schemas updated successfully."); + return 0; +} + +/** + * @param string[] $argv + */ +function main(array $argv) : int{ + $options = [ + "generate" => [["palette upgrade table file", "schema output file"], cmdGenerate(...)], + "test" => [["palette upgrade table file", "schema output file"], cmdTest(...)], + "update" => [["schema input file", "old palette file", "updated schema output file"], cmdUpdate(...)], + "update-all" => [["schema folder", "path to BlockPaletteArchive"], cmdUpdateAll(...)] + ]; + + $selected = $argv[1] ?? null; + if($selected === null || !isset($options[$selected])){ + fwrite(STDERR, "Available commands:\n"); + foreach($options as $command => [$args, $callback]){ + fwrite(STDERR, " - $command " . implode(" ", array_map(fn(string $a) => "<$a>", $args)) . "\n"); + } + return 1; + } + + $callback = $options[$selected][1]; + if(count($argv) !== count($options[$selected][0]) + 2){ + fwrite(STDERR, "Usage: {$argv[0]} $selected " . implode(" ", array_map(fn(string $a) => "<$a>", $options[$selected][0])) . "\n"); + return 1; + } + return $callback($argv); +} + exit(main($argv));