From cd759984108059c634f4303350088dc199eee9ed Mon Sep 17 00:00:00 2001 From: Moulberry Date: Wed, 6 Sep 2023 11:01:12 +0800 Subject: [PATCH] Implement ProtocolV5 (Block Entity Support) --- .../com/moulberry/axiom/AxiomConstants.java | 2 +- .../java/com/moulberry/axiom/AxiomPaper.java | 33 ++++--- .../moulberry/axiom/buffer/BlockBuffer.java | 49 ++++++++++ .../axiom/buffer/CompressedBlockEntity.java | 66 +++++++++++++ .../RequestBlockEntityPacketListener.java | 90 ++++++++++++++++++ .../packet/SetBlockBufferPacketListener.java | 53 +++++++---- .../zstd_dictionaries/block_entities_v1.dict | Bin 0 -> 32768 bytes 7 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/moulberry/axiom/buffer/CompressedBlockEntity.java create mode 100644 src/main/java/com/moulberry/axiom/packet/RequestBlockEntityPacketListener.java create mode 100644 src/main/resources/zstd_dictionaries/block_entities_v1.dict diff --git a/src/main/java/com/moulberry/axiom/AxiomConstants.java b/src/main/java/com/moulberry/axiom/AxiomConstants.java index a80c6cd..f553d14 100644 --- a/src/main/java/com/moulberry/axiom/AxiomConstants.java +++ b/src/main/java/com/moulberry/axiom/AxiomConstants.java @@ -12,7 +12,7 @@ public class AxiomConstants { } } - public static final int API_VERSION = 4; + public static final int API_VERSION = 5; public static final NamespacedKey ACTIVE_HOTBAR_INDEX = new NamespacedKey("axiom", "active_hotbar_index"); public static final NamespacedKey HOTBAR_DATA = new NamespacedKey("axiom", "hotbar_data"); diff --git a/src/main/java/com/moulberry/axiom/AxiomPaper.java b/src/main/java/com/moulberry/axiom/AxiomPaper.java index 8ad8238..d79faa7 100644 --- a/src/main/java/com/moulberry/axiom/AxiomPaper.java +++ b/src/main/java/com/moulberry/axiom/AxiomPaper.java @@ -18,11 +18,11 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.messaging.Messenger; import org.checkerframework.checker.nullness.qual.NonNull; -import java.util.HashSet; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; public class AxiomPaper extends JavaPlugin implements Listener { @@ -30,20 +30,23 @@ public class AxiomPaper extends JavaPlugin implements Listener { public void onEnable() { Bukkit.getPluginManager().registerEvents(this, this); - Bukkit.getMessenger().registerOutgoingPluginChannel(this, "axiom:enable"); - Bukkit.getMessenger().registerOutgoingPluginChannel(this, "axiom:initialize_hotbars"); - Bukkit.getMessenger().registerOutgoingPluginChannel(this, "axiom:set_editor_views"); + Messenger msg = Bukkit.getMessenger(); - HashSet activeAxiomPlayers = new HashSet<>(); + msg.registerOutgoingPluginChannel(this, "axiom:enable"); + msg.registerOutgoingPluginChannel(this, "axiom:initialize_hotbars"); + msg.registerOutgoingPluginChannel(this, "axiom:set_editor_views"); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:hello", new HelloPacketListener(this, activeAxiomPlayers)); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:set_gamemode", new SetGamemodePacketListener()); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:set_fly_speed", new SetFlySpeedPacketListener()); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:set_block", new SetBlockPacketListener(this)); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:set_hotbar_slot", new SetHotbarSlotPacketListener()); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:switch_active_hotbar", new SwitchActiveHotbarPacketListener()); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:teleport", new TeleportPacketListener()); - Bukkit.getMessenger().registerIncomingPluginChannel(this, "axiom:set_editor_views", new SetEditorViewsPacketListener()); + final Set activeAxiomPlayers = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + msg.registerIncomingPluginChannel(this, "axiom:hello", new HelloPacketListener(this, activeAxiomPlayers)); + msg.registerIncomingPluginChannel(this, "axiom:set_gamemode", new SetGamemodePacketListener()); + msg.registerIncomingPluginChannel(this, "axiom:set_fly_speed", new SetFlySpeedPacketListener()); + msg.registerIncomingPluginChannel(this, "axiom:set_block", new SetBlockPacketListener(this)); + msg.registerIncomingPluginChannel(this, "axiom:set_hotbar_slot", new SetHotbarSlotPacketListener()); + msg.registerIncomingPluginChannel(this, "axiom:switch_active_hotbar", new SwitchActiveHotbarPacketListener()); + msg.registerIncomingPluginChannel(this, "axiom:teleport", new TeleportPacketListener()); + msg.registerIncomingPluginChannel(this, "axiom:set_editor_views", new SetEditorViewsPacketListener()); + msg.registerIncomingPluginChannel(this, "axiom:request_block_entity", new RequestBlockEntityPacketListener(this)); SetBlockBufferPacketListener setBlockBufferPacketListener = new SetBlockBufferPacketListener(this); diff --git a/src/main/java/com/moulberry/axiom/buffer/BlockBuffer.java b/src/main/java/com/moulberry/axiom/buffer/BlockBuffer.java index 77992d3..6cc39bb 100644 --- a/src/main/java/com/moulberry/axiom/buffer/BlockBuffer.java +++ b/src/main/java/com/moulberry/axiom/buffer/BlockBuffer.java @@ -6,12 +6,14 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectSet; import it.unimi.dsi.fastutil.shorts.Short2ObjectMap; +import it.unimi.dsi.fastutil.shorts.Short2ObjectOpenHashMap; import net.minecraft.core.BlockPos; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.PalettedContainer; +import org.jetbrains.annotations.Nullable; public class BlockBuffer { @@ -21,6 +23,7 @@ public class BlockBuffer { private PalettedContainer last = null; private long lastId = AxiomConstants.MIN_POSITION_LONG; + private final Long2ObjectMap> blockEntities = new Long2ObjectOpenHashMap<>(); public BlockBuffer() { this.values = new Long2ObjectOpenHashMap<>(); @@ -34,6 +37,17 @@ public class BlockBuffer { for (Long2ObjectMap.Entry> entry : this.entrySet()) { friendlyByteBuf.writeLong(entry.getLongKey()); entry.getValue().write(friendlyByteBuf); + + Short2ObjectMap blockEntities = this.blockEntities.get(entry.getLongKey()); + if (blockEntities != null) { + friendlyByteBuf.writeVarInt(blockEntities.size()); + for (Short2ObjectMap.Entry entry2 : blockEntities.short2ObjectEntrySet()) { + friendlyByteBuf.writeShort(entry2.getShortKey()); + entry2.getValue().write(friendlyByteBuf); + } + } else { + friendlyByteBuf.writeVarInt(0); + } } friendlyByteBuf.writeLong(AxiomConstants.MIN_POSITION_LONG); @@ -48,6 +62,17 @@ public class BlockBuffer { PalettedContainer palettedContainer = buffer.getOrCreateSection(index); palettedContainer.read(friendlyByteBuf); + + int blockEntitySize = Math.min(4096, friendlyByteBuf.readVarInt()); + if (blockEntitySize > 0) { + Short2ObjectMap map = new Short2ObjectOpenHashMap<>(blockEntitySize); + for (int i = 0; i < blockEntitySize; i++) { + short offset = friendlyByteBuf.readShort(); + CompressedBlockEntity blockEntity = CompressedBlockEntity.read(friendlyByteBuf); + map.put(offset, blockEntity); + } + buffer.blockEntities.put(index, map); + } } return buffer; @@ -59,6 +84,30 @@ public class BlockBuffer { this.values.clear(); } + public void putBlockEntity(int x, int y, int z, CompressedBlockEntity blockEntity) { + long cpos = BlockPos.asLong(x >> 4, y >> 4, z >> 4); + Short2ObjectMap chunkMap = this.blockEntities.computeIfAbsent(cpos, k -> new Short2ObjectOpenHashMap<>()); + + int key = (x & 0xF) | ((y & 0xF) << 4) | ((z & 0xF) << 8); + chunkMap.put((short)key, blockEntity); + } + + @Nullable + public CompressedBlockEntity getBlockEntity(int x, int y, int z) { + long cpos = BlockPos.asLong(x >> 4, y >> 4, z >> 4); + Short2ObjectMap chunkMap = this.blockEntities.get(cpos); + + if (chunkMap == null) return null; + + int key = (x & 0xF) | ((y & 0xF) << 4) | ((z & 0xF) << 8); + return chunkMap.get((short)key); + } + + @Nullable + public Short2ObjectMap getBlockEntityChunkMap(long cpos) { + return this.blockEntities.get(cpos); + } + public BlockState get(int x, int y, int z) { var container = this.getSectionForCoord(x, y, z); if (container == null) { diff --git a/src/main/java/com/moulberry/axiom/buffer/CompressedBlockEntity.java b/src/main/java/com/moulberry/axiom/buffer/CompressedBlockEntity.java new file mode 100644 index 0000000..76f9bba --- /dev/null +++ b/src/main/java/com/moulberry/axiom/buffer/CompressedBlockEntity.java @@ -0,0 +1,66 @@ +package com.moulberry.axiom.buffer; + +import com.github.luben.zstd.Zstd; +import com.github.luben.zstd.ZstdDictCompress; +import com.github.luben.zstd.ZstdDictDecompress; +import com.moulberry.axiom.AxiomPaper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.network.FriendlyByteBuf; + +import java.io.*; +import java.util.Objects; + +public record CompressedBlockEntity(int originalSize, byte compressionDict, byte[] compressed) { + + private static ZstdDictCompress zstdDictCompress = null; + private static ZstdDictDecompress zstdDictDecompress = null; + + public static void initialize(AxiomPaper plugin) { + try (InputStream is = Objects.requireNonNull(plugin.getResource("zstd_dictionaries/block_entities_v1.dict"))) { + byte[] bytes = is.readAllBytes(); + zstdDictCompress = new ZstdDictCompress(bytes, Zstd.defaultCompressionLevel()); + zstdDictDecompress = new ZstdDictDecompress(bytes); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static CompressedBlockEntity compress(CompoundTag tag, ByteArrayOutputStream baos) { + try { + baos.reset(); + DataOutputStream dos = new DataOutputStream(baos); + NbtIo.write(tag, dos); + byte[] uncompressed = baos.toByteArray(); + byte[] compressed = Zstd.compress(uncompressed, zstdDictCompress); + return new CompressedBlockEntity(uncompressed.length, (byte) 0, compressed); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public CompoundTag decompress() { + if (this.compressionDict != 0) throw new UnsupportedOperationException("Unknown compression dict: " + this.compressionDict); + + try { + byte[] nbt = Zstd.decompress(this.compressed, zstdDictDecompress, this.originalSize); + return NbtIo.read(new DataInputStream(new ByteArrayInputStream(nbt))); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static CompressedBlockEntity read(FriendlyByteBuf friendlyByteBuf) { + int originalSize = friendlyByteBuf.readVarInt(); + byte compressionDict = friendlyByteBuf.readByte(); + byte[] compressed = friendlyByteBuf.readByteArray(); + return new CompressedBlockEntity(originalSize, compressionDict, compressed); + } + + public void write(FriendlyByteBuf friendlyByteBuf) { + friendlyByteBuf.writeVarInt(this.originalSize); + friendlyByteBuf.writeByte(this.compressionDict); + friendlyByteBuf.writeByteArray(this.compressed); + } + +} diff --git a/src/main/java/com/moulberry/axiom/packet/RequestBlockEntityPacketListener.java b/src/main/java/com/moulberry/axiom/packet/RequestBlockEntityPacketListener.java new file mode 100644 index 0000000..84d3cf0 --- /dev/null +++ b/src/main/java/com/moulberry/axiom/packet/RequestBlockEntityPacketListener.java @@ -0,0 +1,90 @@ +package com.moulberry.axiom.packet; + +import com.moulberry.axiom.AxiomPaper; +import com.moulberry.axiom.buffer.CompressedBlockEntity; +import io.netty.buffer.Unpooled; +import it.unimi.dsi.fastutil.longs.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import org.bukkit.craftbukkit.v1_20_R1.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.bukkit.plugin.messaging.PluginMessageListener; +import org.jetbrains.annotations.NotNull; + +import java.io.ByteArrayOutputStream; + +public class RequestBlockEntityPacketListener implements PluginMessageListener { + + private final AxiomPaper plugin; + + public RequestBlockEntityPacketListener(AxiomPaper plugin) { + this.plugin = plugin; + } + + @Override + public void onPluginMessageReceived(@NotNull String channel, @NotNull Player bukkitPlayer, @NotNull byte[] message) { + FriendlyByteBuf friendlyByteBuf = new FriendlyByteBuf(Unpooled.wrappedBuffer(message)); + long id = friendlyByteBuf.readLong(); + + if (!bukkitPlayer.hasPermission("axiom.*")) { + // We always send an 'empty' response in order to make the client happy + sendEmptyResponse(bukkitPlayer, id); + return; + } + + ServerPlayer player = ((CraftPlayer)bukkitPlayer).getHandle(); + MinecraftServer server = player.getServer(); + if (server == null) { + sendEmptyResponse(bukkitPlayer, id); + return; + } + + ResourceKey worldKey = friendlyByteBuf.readResourceKey(Registries.DIMENSION); + ServerLevel level = server.getLevel(worldKey); + if (level == null) { + sendEmptyResponse(bukkitPlayer, id); + return; + } + + Long2ObjectMap map = new Long2ObjectOpenHashMap<>(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + BlockPos.MutableBlockPos mutableBlockPos = new BlockPos.MutableBlockPos(); + + // Save and compress block entities + int count = friendlyByteBuf.readVarInt(); + for (int i = 0; i < count; i++) { + long pos = friendlyByteBuf.readLong(); + BlockEntity blockEntity = level.getBlockEntity(mutableBlockPos.set(pos)); + if (blockEntity != null) { + CompoundTag tag = blockEntity.saveWithoutMetadata(); + map.put(pos, CompressedBlockEntity.compress(tag, baos)); + } + } + + // Send response packet + FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer(16)); + buf.writeLong(id); + buf.writeVarInt(map.size()); + for (Long2ObjectMap.Entry entry : map.long2ObjectEntrySet()) { + buf.writeLong(entry.getLongKey()); + entry.getValue().write(buf); + } + bukkitPlayer.sendPluginMessage(this.plugin, "axiom:block_entities", buf.accessByteBufWithCorrectSize()); + } + + private void sendEmptyResponse(Player player, long id) { + FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer(16)); + buf.writeLong(id); + buf.writeByte(0); // no block entities + player.sendPluginMessage(this.plugin, "axiom:block_entities", buf.accessByteBufWithCorrectSize()); + } + +} diff --git a/src/main/java/com/moulberry/axiom/packet/SetBlockBufferPacketListener.java b/src/main/java/com/moulberry/axiom/packet/SetBlockBufferPacketListener.java index 03c1aa2..97a515f 100644 --- a/src/main/java/com/moulberry/axiom/packet/SetBlockBufferPacketListener.java +++ b/src/main/java/com/moulberry/axiom/packet/SetBlockBufferPacketListener.java @@ -3,8 +3,10 @@ package com.moulberry.axiom.packet; import com.moulberry.axiom.AxiomPaper; import com.moulberry.axiom.buffer.BiomeBuffer; import com.moulberry.axiom.buffer.BlockBuffer; +import com.moulberry.axiom.buffer.CompressedBlockEntity; import io.netty.buffer.Unpooled; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.shorts.Short2ObjectMap; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.Registry; @@ -125,6 +127,8 @@ public class SetBlockBufferPacketListener { } } + Short2ObjectMap blockEntityChunkMap = buffer.getBlockEntityChunkMap(entry.getLongKey()); + sectionStates.acquire(); try { for (int x = 0; x < 16; x++) { @@ -159,26 +163,41 @@ public class SetBlockBufferPacketListener { } } - boolean oldHasBlockEntity = old.hasBlockEntity(); - if (old.is(block)) { - if (blockState.hasBlockEntity()) { - BlockEntity blockEntity = chunk.getBlockEntity(blockPos, LevelChunk.EntityCreationType.CHECK); - if (blockEntity == null) { - blockEntity = ((EntityBlock)block).newBlockEntity(blockPos, blockState); - if (blockEntity != null) { - chunk.addAndRegisterBlockEntity(blockEntity); - } - } else { - blockEntity.setBlockState(blockState); + if (blockState.hasBlockEntity()) { + BlockEntity blockEntity = chunk.getBlockEntity(blockPos, LevelChunk.EntityCreationType.CHECK); - try { - this.updateBlockEntityTicker.invoke(chunk, blockEntity); - } catch (IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(e); - } + if (blockEntity == null) { + // There isn't a block entity here, create it! + blockEntity = ((EntityBlock)block).newBlockEntity(blockPos, blockState); + if (blockEntity != null) { + chunk.addAndRegisterBlockEntity(blockEntity); + } + } else if (blockEntity.getType().isValid(blockState)) { + // Block entity is here and the type is correct + blockEntity.setBlockState(blockState); + + try { + this.updateBlockEntityTicker.invoke(chunk, blockEntity); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } else { + // Block entity type isn't correct, we need to recreate it + chunk.removeBlockEntity(blockPos); + + blockEntity = ((EntityBlock)block).newBlockEntity(blockPos, blockState); + if (blockEntity != null) { + chunk.addAndRegisterBlockEntity(blockEntity); } } - } else if (oldHasBlockEntity) { + if (blockEntity != null && blockEntityChunkMap != null) { + int key = x | (y << 4) | (z << 8); + CompressedBlockEntity savedBlockEntity = blockEntityChunkMap.get((short) key); + if (savedBlockEntity != null) { + blockEntity.load(savedBlockEntity.decompress()); + } + } + } else if (old.hasBlockEntity()) { chunk.removeBlockEntity(blockPos); } diff --git a/src/main/resources/zstd_dictionaries/block_entities_v1.dict b/src/main/resources/zstd_dictionaries/block_entities_v1.dict new file mode 100644 index 0000000000000000000000000000000000000000..f6d838a708a80cd0f557a88c4c8bd66308e503f1 GIT binary patch literal 32768 zcmcgV3v3+6b#pwCA}QJW*|IFlF+(S<)7Y^@TK*`FO-j^}!w_x<+Xe;+<tZcHJ%7d??K|{|+2?K-`oE_( z2yqgAnf?2vfBMcBYYzwC-ubqle&m(yxBkYr-~6Zl@OvNsqO<#VKmY8rli&HfFTQy) zc+bVFemzsxXwZqU~p17-iEB>y(!yxy=uH2P5MesP_7nTe?e1rhn~An!R~3o z26m-bYlPLn_iM$H3k%Xn_h_Vz3hAaMQdsu`8==F{uW5M0uzm%>zM#;G$yiX4o&>iW{3?VYT5d+Cj18UlfDL;=x7RuAj7E zUn(Lmau>`Lj-)r5$*3K=e*Vc1`l^I?{_CHR$%{pq5v$ zLnksSxj{=LsU(wwdZSifbi88SG0-<@Xi~Tvngf^pI_zH&gcwBYz;Mv;ie0 z5B$=l;j-`g0l-NsP^>qCx@*HEW!EV$9$vCN7~|5gRgN6r1E_M<@)qo3-C95(P02ns z-`73|zchzkt^$7ml&BecTkLDa0wYzz8d>hD|YQ6Fbnk|WbeEW9GCPt-oj7>0AU0h zGZgrdgx^S0psT)Nxq~4VM)HADVmg@~=N1TS|gy@ATg34VnMq94xtD zN}BYZ>okrWq9gk3wV}_&&=aJ+(>rEcl^8@~oj_mgGnjr@SsO`Qn@-%j7e>}Xcvoze zBoT22NvT>0Y}4zti#9@M!6V3hG03)d^dikZj&>9#8s~O&C)rVQlFa>9 zz$J#R6@kkR!0Z@*+M_KbxGnUn%tD5Z1uH0)Ezg+KhcqzRX=Z$VYq1zwbr%#?yXvhP zI&G>why}p-E?a?#oZg|K$y9G^n(B(<2h0U**O2L`r|Nt53YhVdreRFOQb58eF8e+N z2oPV`^BQ`ShNf6#YmCqcG84cW#Fp`Rbf2DSy8>pbUhv&Y(QAOW zHX>!{+M%FX^lHLg@Hr5@#^T9kpgAgrwp}rV_v?9gsgallSDdB{8)>^)f!)I8#{pMZ zDOLif9s=)-PK}YSGNzTRAh6pT-#b|rWo5aJXwKmn@u(#Mg$1GUF4!+beqfo zGj9ulmwn&Wdc9M_k`;JEQ{c)g7DlvQIk3U#*Zt*6Gv`X01EzCQ zK8oNei~%!3P4=K0JIDwy*#|RU2hm4sA6I8%#L~K-rejF2^~RL$`@lg6nK@@hHS|se z%`&~RZ>(r9K?R3^sTF_OQ}!sGK#EmTx?51`AR~NUE1fi9Y7VBtRheD1bK>G%G}=ux z+8^uDFoC=|?!;L_94Q;J;98+EO-5Qthgt4a_AQqZ99hmVGDr%{b2o01-l};=s>{R! zjep&>yk-CaD~Q!Jm>K}!3V3-ir-tF=0a|C-cklejdF%NjfA-uLc0d2WKbzT?EzADH z*DA}NU7e~;jZ9sbgA9A_=!xYp*#KwsLWDW`sLNhTP z(v-kg6l=DX)_F2#w>EXY9ynpm3ZRx%EC*0)fgDL&3ZunT$zh=(!wRcbVD`$Oq>DLS zQOI;MVU%$Z3rUPiCG(kqAS1tILs@(j`VAN2PtOp)M{|^{D2rnJi%!K^1%sqY0LUS! z9@-N#3C>Vj65k_TKB#nA^l@FjDXzZOqyGqVHGOSX{|vw@_? z4Dj&=7~@qf944$vdtWE;y+l}Lh9zNT!^Yh)Fv@Lda6n9Ke~Au_`nnt}rc zjfM0g)VzWP6B2LH&=eMQp?JRrr=)?PN$YFIPjoKeR5*^At<}~ zwNt)p0k-3eGYROdfnTS;neTxgU07*_2^f zqGW{(a_HlT*k$-R;g^3t9D?JFc?Sx$PrM3T`2lf79=5=&Q48r|kwMw2)zJ!U5}P3m zhYJ3}v{SP~U=SY9#s1qQwrLQk$GC0b+6s1(w1y%L%YP`bBVxDVOXSPSGaLX^qoKPV z?8cd!4C3s}p&{JkyBc-iCz8@S-2Kf5j{e(&yHEV%Gyn4A`|rEl`~7U;?rV0aG&KVU zNvmZDZxrmBg}qZ3jzHK9e|C}~@75VS(Lw(-G#yy*r-a0#Rk(Wf>R*!xoj}Ec z^E(Pmn9XT(+63PINCS!|G|peqI0YKV1ZPsLl&rIQKnV_Y(P7Brh91#>5PnA?18AwL zTsLWkZ_*4$^bBfHmT%I@VqCKc`x{xYd&IVab#V%+X{D9JUgY4xqv-1qJ5Je;H$ZtR zCuOpA3KL(;U-<65x+j-%zT=tvZKELvEFa;amB9&sH^pulVxD?ijDdMEHt zqNgS4e?X-F0xg`JHFNDXzf=s73_H*SDf%|xWRBOIOniMnY>~f0KnG_x4rDHv3PYgR zCszxu8LQK2vZ0nZW21^9wA`hBG4P1*N7EKmyTonHFiJY9@b1v5!SP$M(f~$bL@BrQ zKnWAEA|%$B7r^OsV3qwydfg3q@kTZXe88%4vz#6|P*gLM)C4UN~>ZY7d0}M#7I<&N#lDXp8{i5L%t4u5BVQPxz}H@a04hqsRmF^{!}$ z@Vl<)p{^?ey+kLV^b(b{5qRnsKn3oL<21x2y(lS4lm}3X#oRuYNt)3w;9`TolOUpc zmTB}g;_HKA(5!2Yqh8n zGWZGwCN{7v^}yfWn&&x=irb-PSPY?Qr4HXW=Ecu7$x#Q*vRc?mq+tpcQWF7e2}8}1 z>W;VAQY~PmOf6nz^#Pr`CSkjs@Vg2d=Iq!OqO|r=`fWwa4SvyD`!1!mQWS*P2Tc{<4qtYIP#yE8+A?_L}D&6&YF*i&(cMk(u^ zMT^iIug$Q6wG6h4toWnN!<%*DNLC~Z&X~rba?P4>sB*I=9Ez2);Q(V$f@PQre@5rE zCo{_>I9QXl?Ef3q1`dXAEu(8UlTjFNqtREoYb&0&qnOWY5rRJ(CW>Fx|XVr35*>~s_) zv>oF6G^(Se6m=@Ql;<$%x)!Cxv=H%S!A^F*YGb`y@9CJzJlNV8u=>VQ9_BNe3t48N zgVxJRs}(aq@xx9_Bft(WFSm{G?Jg7L_7Y%MigZ!1rNm~SXlquAw5)ZdNcTd04@O2W zhOM|KtqAfrPo|G>K(Ud27d>UDz)=AbOPK6ae%|%jw~hQ;PgrjEDPM|0_ANeSl=#*Z z0ilbA>*eE)tb82Fy0bqb!1YqJJu5}~k`$p69EitZ>>v;f`%0@Y%$y=$e-OLXRfS;M zTF|17qu!!NO(-%eA*gc_)OdCNr?k2Ad8)P#TN1-&!`iwotZnPU+P*HV9qYonVO>}^ zt_ur#9Mx3-vdq~y4+pX9z}mGgENI|Z$6QH|(`8-RT+dR|?&OA($_H9voGk%%@F?Qz zgm2VrZTPvqq?KrqguQ;sPX!lcCEywYUyMA<0%|ct86~Q#@DqJZl+N{K*^$6?4WT8I z__*3QXB<}-3%ZWGe{J-hCh)G~-qU^DElpt)XS0rbU-xl2fALHSBet&e;yn`t?7&}6 z@42kwKG1#K>lq(rvW|Oy_i;JBb6LlI``X5x%{uOZ?&I=(!-PB*slWc7pM~&Y-GQU0 zqP=1(e~uUb)V&rw`hor_NGh?(PJ4T}pA@s`tjOC5?fUhhC1Z<>vn7LzjA+T|A|qNd zyvT@_j4v{xB?F9%Xvqj8BU&=V$cUDVF*2ehgN%%5$tWWuS~AS&hL$bPSYRga{iN#e z=HWO)L9 z0t27ET+kW*2E~EQ<5RnHW_W6N&J2Ik8tI`c5ShoPyAYY-cW0#s#0d2JW|jKSIx ziJo@)a7vdvnBh)TH+36uCdKbf4#mEGK!4*Bo;Xfm>!n);(C)?hrj7j2Q9@>TI!4G0 zPfd!M;i&p(^^pItSJ+U<|tF7W#ZH0noq!SlaiiifLi0a z0){jq6#KgDpWN=o+Q2ovK~{RZ0&3OBNUs*GjNqEymhB;}_}djwkJ$2Pz14_a0rigI zR;*v+wB{eAvThHRN*_@?OiVy;mxn?K19zCfajGKX7hfB<|yH+Z=(GVJ!2_0Qkq#ZIELV%3z z`qDsBJqVJECtRnny|6jG(GG%T9d7FGKvFl-W8(7Rc0_YbqaNuoT^n8|(OfdBL2yhp zkapsikz%IC%Qg5#Yimm78Y-pw?=*Z~Knkc@1Sf1dP? zAu&SihD!tL_)}ga5(88IQMd!>L@QtndkO=%L zFL2%XTp3*3u>*J+2%@la(8&5z_&E!oo`Iip@bfb8uK$=7o{pe#S`3K6@d$32!8;K2 z=v5z(Z-w?le=|Bd2H^T86>BA3u%Qp!DZi}-&j#W9JkS~rPb|Q&n#Tu&nD(`T=z5Ld&kX$K5M7 zBj$v;35alglinjh03!8>feA2@Rnlmnc00OzFdg1?ko zjf4RU#-TesP=vw0i@t)q=h%lIzw+{@c71GO?-_)||Gx6vrdxjET={IT=!4gP+^9M} zgftDnW=%olW(9Z+>k)fC!sSVZ_n+{N2Ee)supmF~1i3q*Z{id_Z~{z~t3LF}r(k59 z8HVvHllq;m9{j7V;{k|Mf>9eZqx8=Kkq2p#6py{<%P+q8q7Y-}pL_Aeufosr4@O7# z;!uNfC^Njg@6!V}IN(CV)vI~2`w;9#2(&JHt@xCK7H!Kr3CeM%jxGPNoPLyU#W%Pu zhuhQ?>7S%T8d)thjWS=>T&6mjwc%*@egcIL91kChg3!0~y9*Shv9KB6;OmA(*Jydg z7TdGCq0yyU)~=0juXvHw6_WyAro?pG(QQhqIWfMdmUP+B0s2GNE3~;a?@>_giA_jP zJ9L|nF2OQH^~_k;CDNr>##8C#yUj=MZ7@$`-Zzx-Txw;MSTVobfem!0_D#(yHtXo` zMp`<#x&S5{uDrzSkA~;zw>K-s@-2Od3cki!;wn&w!{GcT{F& zps6U%>PX6ScVH6L#aY;ydh^l@Pc?DYUt12GS_oI$6*baSv!GPov<4$8W9Q_FNeWI` zdbU!Qje`*dvx9YwKWF8vcf;S|Y3L_g)sgOFfKMd>v8-WQo7BW?Rd7B5PN+B!SCXnJ z5u~6dNq!10oo=Syh%$^N!^y|=mK@VPY~aXHvZ6-`Dln<3$u6?t>K5EnPQj5SWU;Z% zzfz!N{g^3QcI4w@;iB!@P;Ds6n}jJmvVBa_;rcrwrXb1KG1XqSF}m@=ho^wa!ZE2` zXn1X2=OLrU2Cg^e;lkKFv^}Q=CPT(T>NXUR>0&&VDQjv#vRG`uQiDHrYxXw%P8i2Zg3V`!jgnD z^T9jYQ`C}}vsK9=PYRACobjr~6Ay-9l5rLZvA1QCkJy;KCt+u0Q2Qd-2?qmkcn3I& z+_dJA-18ij9u9^}1kDQ%W)6~n#va)c(r$Rt0tOBT&Z})--Xbr}%!>`hvY`1im7#;= zoJA;L0x*Gsh_TtnqPM}Ik;t>{lJ7aD;K)z2NCux>R<*1OJXMiGAXm-ym&As@=4i+u zFU@RNvyP>QHo`z|n$e=odNF3B3<8~9a~mZ~sHu_Z(3%kq?1l~?sFDTXtuRv?Zbn;R z4idL~)#Xh+tXUn&2t&m;3s)fOG;i(6hN9*Zhz6D9h21NO^(H_UQVS%K8aAJriC;*@ z2BsFMBu1=8OSLC>TBcs{{j^^r31YjU0<5+OBuY#LFgR=ro^FI7isG+J8jg`DF-5=z zZg|H8^*}y(2@k-T2Wku&2^Cv-n`bNy21ym$nhg6%62=y<_Os1)&`AXYSIlj*b~-d- zW-__^tIrYKz7t#mYyl+uQsGrplYKy>=Wm{;xGRQB5pez+-)?VEi zYQrxIN;#%(>@ioHS_?k;ZicCEJVRxJ_Q;QtHAH4NWeBQ+lVs_z9bAW`L5-I!X?Rxc zV5eRKeSR8$qlYAb@a0)IbS_H?=nAM0#oGhsMC{QTcs+$bo=lIaq+ayF{)&I4kEutK zQ8pLMGc3usYvALv^s&`D$?K)&d#?dCz-3(MuV&zOr)||I{p~k_Cej@QT>-VAENYbo zDou9UN>JJZu7Cgk<2B&;`l$pJvuxw_Dd*?KJ^*l0J?9!B+T}*+eTO=GZrdRgTMPw}R&jNF!>6?zl5_p<8L+ zrm+AI1t}Y?cVd5HwVf8BCu_fmu1|^R`ir3#$Td3i>C_t`uu}d;h+KKsIewW~cnxz9 zci+=-B(@E@xzS9C+W?2vydjT?TZ!dIMit1HcLO8G-3^SqbQ`ceiCvenqaU(+6y+eXfNoc_5xZJt5b!`@DM+q zWW^y^9_YeJ1_PiX0n6y|k=D9r!|jCl7%azXc<_>yg-@wznDmJzq>%Cum3MOJxBXa> z4DTjl_ewE@f=)&A#2oy#;Yq8Oiyor3JtbL|(~4S_{@LydgY8xKvu48qPGjitex*;H)a2u| zDa%^SaZ{iTnP*X~?9BRjbR&w+s$d|6wFWMln)~a_MP{U2e0shJXKLno_+M1#)Tvk5 z)9pM4v?Oxv*3VH;QvgYMwMYPq?st~VkX zA7&F7+Pv3!BeKz!osXX&K6VmkmikB=Q}*%S_)U69mXen(w+xT*Y57jN$M%p(-`D~& zn`0zJMt_mpPD{kP5i0rnI+xtEg17VP&FSiyZC7OWxmT2ba9I(GD(oW1}5h^14Plxa4h(c5vn@69shb;5yvXi1FFn zVd^qohdUav@sf8m+R>N1ozV_1c`u_KoW7X>UE^BRlJ_rI;m!Q>YZ}(vQ6e^Uf=y-^ zonVs*Mkm;0e$fdwnO<~)O=cIJV3Ve^6Kv9Wc7jcs&z7*+4CvF%%p#{V1M@%y7*P}1 z6JV#1>A$oA?%=CgfT7xBu%$#>C-d*#QKZD$QdsL~3YuJVMrevLGeT2z*$tXpPDdvF EKe6YLy8r+H literal 0 HcmV?d00001