| … | … | @@ -0,0 +1,360 @@ |
| 1 | // ============================================================================ |
| 2 | // HASH PARITY — is the GPU's integer hash the SAME FUNCTION as the CPU's? |
| 3 | // |
| 4 | // WHY THIS IS THE FIRST GATE OF THE NOISE WORK. |
| 5 | // |
| 6 | // Everything above the hash inherits it. Voronoi's cell offsets are this hash; |
| 7 | // Perlin's lattice gradients are this hash; Blender's White Noise Texture node |
| 8 | // is this hash and nothing else. Get it wrong and nothing above it can ever |
| 9 | // match, and the failure looks like the STRUCTURE rather than the hash -- |
| 10 | // which is exactly how ProcNodes_Hash2D cost this repo a terrain chain tuned |
| 11 | // against a correlation of -0.0043 that could not converge. |
| 12 | // |
| 13 | // It is also the one layer where "agree" can mean BIT-EXACT. The op-parity |
| 14 | // gate next door compares floats at 1e-5 because libm and a GPU part company |
| 15 | // on sin/pow. This is uint32 arithmetic: GLSL requires unsigned overflow to |
| 16 | // wrap and C++ defines it as modulo 2^32, so the two languages compute the |
| 17 | // same integers or they do not. There is no tolerance to hide in. |
| 18 | // |
| 19 | // NEITHER SIDE IS WRITTEN FOR THE TEST. |
| 20 | // CPU src/shared/blender_noise.h, the header the nodes call, included |
| 21 | // through tests/stub_include so no engine is needed. |
| 22 | // GPU text EXTRACTED from the shipping source at run time, never |
| 23 | // retyped here. The erosion filter's lesson was that a transcription |
| 24 | // used AS the reference can only ever agree with itself. |
| 25 | // |
| 26 | // Build (no engine, no godot-cpp, no scons): |
| 27 | // g++ -O2 -std=c++17 -I tests/stub_include -I src/shared \ |
| 28 | // -o /tmp/hash_parity tests/test_hash_parity.cpp -lvulkan |
| 29 | // |
| 30 | // Run: |
| 31 | // /tmp/hash_parity # default subject, see SUBJECT below |
| 32 | // /tmp/hash_parity <file> <marker> # extract a raw string from <file> |
| 33 | // |
| 34 | // Exit status is 0 when every case agrees exactly. |
| 35 | // ============================================================================ |
| 36 | |
| 37 | #include <vulkan/vulkan.h> |
| 38 | |
| 39 | #include <cstdint> |
| 40 | #include <cstdio> |
| 41 | #include <cstdlib> |
| 42 | #include <cstring> |
| 43 | #include <fstream> |
| 44 | #include <sstream> |
| 45 | #include <string> |
| 46 | #include <vector> |
| 47 | |
| 48 | #include "blender_noise.h" |
| 49 | |
| 50 | // The subject, by default: the hand-written GLSL noise library that ships in |
| 51 | // GpuCompiler::get_noise_library_glsl(). It carries its own Jenkins hash, |
| 52 | // written independently of blender_noise.h's port of the same function from |
| 53 | // Blender. Nothing has ever compared them. That is the question this run asks. |
| 54 | static const char *SUBJECT_FILE = "src/gpu/gpu_compiler.cpp"; |
| 55 | static const char *SUBJECT_MARK = "get_noise_library_glsl"; |
| 56 | // The name the subject text gives the 3-argument Jenkins hash. |
| 57 | static const char *SUBJECT_FN3 = "jenkins_hash3"; |
| 58 | |
| 59 | static void die(const char *what, VkResult r) { |
| 60 | fprintf(stderr, "FATAL: %s -> VkResult %d\n", what, (int)r); |
| 61 | exit(1); |
| 62 | } |
| 63 | #define VK(x) do { VkResult _r = (x); if (_r != VK_SUCCESS) die(#x, _r); } while (0) |
| 64 | |
| 65 | static std::string read_file(const std::string &path) { |
| 66 | std::ifstream f(path); |
| 67 | if (!f) { fprintf(stderr, "FATAL: cannot read %s\n", path.c_str()); exit(1); } |
| 68 | std::stringstream ss; |
| 69 | ss << f.rdbuf(); |
| 70 | return ss.str(); |
| 71 | } |
| 72 | |
| 73 | // Pull the GLSL out of a C++ raw string literal that follows `marker`. The |
| 74 | // point is that the gate compiles the SAME BYTES the engine hands the driver; |
| 75 | // re-typing them here would put the gate's blind spot exactly on the change. |
| 76 | static std::string extract_raw_string(const std::string &src, const std::string &marker) { |
| 77 | size_t m = src.find(marker); |
| 78 | if (m == std::string::npos) { |
| 79 | fprintf(stderr, "FATAL: marker '%s' not found\n", marker.c_str()); |
| 80 | exit(1); |
| 81 | } |
| 82 | size_t open = src.find("R\"(", m); |
| 83 | if (open == std::string::npos) { |
| 84 | fprintf(stderr, "FATAL: no R\"( after '%s'\n", marker.c_str()); |
| 85 | exit(1); |
| 86 | } |
| 87 | open += 3; |
| 88 | size_t close = src.find(")\"", open); |
| 89 | if (close == std::string::npos) { |
| 90 | fprintf(stderr, "FATAL: unterminated raw string after '%s'\n", marker.c_str()); |
| 91 | exit(1); |
| 92 | } |
| 93 | return src.substr(open, close - open); |
| 94 | } |
| 95 | |
| 96 | static std::vector<uint32_t> compile_glsl(const std::string &src) { |
| 97 | char in_path[] = "/tmp/proc_hash_parity_XXXXXX"; |
| 98 | int fd = mkstemp(in_path); |
| 99 | if (fd < 0) { perror("mkstemp"); exit(1); } |
| 100 | FILE *f = fdopen(fd, "w"); |
| 101 | fwrite(src.data(), 1, src.size(), f); |
| 102 | fclose(f); |
| 103 | std::string spv = std::string(in_path) + ".spv"; |
| 104 | std::string cmd = "glslc -fshader-stage=comp -o " + spv + " " + in_path; |
| 105 | if (system(cmd.c_str()) != 0) { |
| 106 | fprintf(stderr, "FATAL: glslc failed (source left at %s)\n", in_path); |
| 107 | exit(1); |
| 108 | } |
| 109 | FILE *sf = fopen(spv.c_str(), "rb"); |
| 110 | fseek(sf, 0, SEEK_END); long n = ftell(sf); fseek(sf, 0, SEEK_SET); |
| 111 | std::vector<uint32_t> out(n / 4); |
| 112 | if (fread(out.data(), 1, n, sf) != (size_t)n) { fprintf(stderr, "FATAL: short read\n"); exit(1); } |
| 113 | fclose(sf); |
| 114 | remove(in_path); remove(spv.c_str()); |
| 115 | return out; |
| 116 | } |
| 117 | |
| 118 | struct Ctx { |
| 119 | VkInstance instance = VK_NULL_HANDLE; |
| 120 | VkPhysicalDevice phys = VK_NULL_HANDLE; |
| 121 | VkDevice dev = VK_NULL_HANDLE; |
| 122 | VkQueue queue = VK_NULL_HANDLE; |
| 123 | uint32_t qfam = 0; |
| 124 | VkCommandPool cmdpool = VK_NULL_HANDLE; |
| 125 | VkPhysicalDeviceMemoryProperties memprops{}; |
| 126 | char devname[256] = {0}; |
| 127 | }; |
| 128 | |
| 129 | static uint32_t find_mem(Ctx &c, uint32_t bits, VkMemoryPropertyFlags want) { |
| 130 | for (uint32_t i = 0; i < c.memprops.memoryTypeCount; i++) { |
| 131 | if ((bits & (1u << i)) && (c.memprops.memoryTypes[i].propertyFlags & want) == want) { |
| 132 | return i; |
| 133 | } |
| 134 | } |
| 135 | fprintf(stderr, "FATAL: no memory type with flags 0x%x\n", want); |
| 136 | exit(1); |
| 137 | } |
| 138 | |
| 139 | static void init_vulkan(Ctx &c) { |
| 140 | VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO}; |
| 141 | app.pApplicationName = "proc-nodes hash parity"; |
| 142 | app.apiVersion = VK_API_VERSION_1_1; |
| 143 | VkInstanceCreateInfo ii{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; |
| 144 | ii.pApplicationInfo = &app; |
| 145 | VK(vkCreateInstance(&ii, nullptr, &c.instance)); |
| 146 | |
| 147 | uint32_t n = 0; |
| 148 | VK(vkEnumeratePhysicalDevices(c.instance, &n, nullptr)); |
| 149 | if (!n) { fprintf(stderr, "FATAL: no Vulkan device\n"); exit(1); } |
| 150 | std::vector<VkPhysicalDevice> devs(n); |
| 151 | VK(vkEnumeratePhysicalDevices(c.instance, &n, devs.data())); |
| 152 | c.phys = devs[0]; |
| 153 | for (auto d : devs) { |
| 154 | VkPhysicalDeviceProperties p; |
| 155 | vkGetPhysicalDeviceProperties(d, &p); |
| 156 | if (p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) { c.phys = d; break; } |
| 157 | } |
| 158 | VkPhysicalDeviceProperties props; |
| 159 | vkGetPhysicalDeviceProperties(c.phys, &props); |
| 160 | snprintf(c.devname, sizeof(c.devname), "%s", props.deviceName); |
| 161 | vkGetPhysicalDeviceMemoryProperties(c.phys, &c.memprops); |
| 162 | |
| 163 | uint32_t qn = 0; |
| 164 | vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, nullptr); |
| 165 | std::vector<VkQueueFamilyProperties> qs(qn); |
| 166 | vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, qs.data()); |
| 167 | bool found = false; |
| 168 | for (uint32_t i = 0; i < qn; i++) { |
| 169 | if (qs[i].queueFlags & VK_QUEUE_COMPUTE_BIT) { c.qfam = i; found = true; break; } |
| 170 | } |
| 171 | if (!found) { fprintf(stderr, "FATAL: no compute queue\n"); exit(1); } |
| 172 | |
| 173 | float prio = 1.0f; |
| 174 | VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO}; |
| 175 | qi.queueFamilyIndex = c.qfam; qi.queueCount = 1; qi.pQueuePriorities = &prio; |
| 176 | VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO}; |
| 177 | di.queueCreateInfoCount = 1; di.pQueueCreateInfos = &qi; |
| 178 | VK(vkCreateDevice(c.phys, &di, nullptr, &c.dev)); |
| 179 | vkGetDeviceQueue(c.dev, c.qfam, 0, &c.queue); |
| 180 | |
| 181 | VkCommandPoolCreateInfo ci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; |
| 182 | ci.queueFamilyIndex = c.qfam; |
| 183 | ci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; |
| 184 | VK(vkCreateCommandPool(c.dev, &ci, nullptr, &c.cmdpool)); |
| 185 | } |
| 186 | |
| 187 | // --- the cases ------------------------------------------------------------- |
| 188 | // |
| 189 | // Chosen for where an integer hash actually breaks: zero, the all-ones word a |
| 190 | // shift can eat, single-bit words that expose a rotate written as a shift, the |
| 191 | // two's-complement spelling of a negative lattice coordinate (Blender indexes |
| 192 | // its lattice with signed ints cast to uint, so these are ORDINARY inputs, not |
| 193 | // exotic ones), and the bit patterns of real floats, since hash_float feeds |
| 194 | // exactly those. |
| 195 | |
| 196 | struct Case3 { |
| 197 | uint32_t x, y, z; |
| 198 | const char *why; |
| 199 | }; |
| 200 | |
| 201 | static uint32_t bits_of(float f) { |
| 202 | uint32_t u; |
| 203 | memcpy(&u, &f, sizeof(u)); |
| 204 | return u; |
| 205 | } |
| 206 | |
| 207 | int main(int argc, char **argv) { |
| 208 | const std::string file = (argc > 1) ? argv[1] : SUBJECT_FILE; |
| 209 | const std::string mark = (argc > 2) ? argv[2] : SUBJECT_MARK; |
| 210 | |
| 211 | std::string lib = extract_raw_string(read_file(file), mark); |
| 212 | if (lib.find(SUBJECT_FN3) == std::string::npos) { |
| 213 | fprintf(stderr, "FATAL: extracted text has no %s\n", SUBJECT_FN3); |
| 214 | return 1; |
| 215 | } |
| 216 | |
| 217 | std::vector<Case3> cases = { |
| 218 | { 0u, 0u, 0u, "zero" }, |
| 219 | { 1u, 0u, 0u, "one bit, low word" }, |
| 220 | { 0u, 1u, 0u, "one bit, mid word" }, |
| 221 | { 0u, 0u, 1u, "one bit, high word" }, |
| 222 | { 1u, 2u, 3u, "small lattice" }, |
| 223 | { 7u, 11u, 13u, "small lattice, odd" }, |
| 224 | { 0xFFFFFFFFu, 0u, 0u, "all ones, low" }, |
| 225 | { 0u, 0xFFFFFFFFu, 0u, "all ones, mid" }, |
| 226 | { 0u, 0u, 0xFFFFFFFFu, "all ones, high" }, |
| 227 | { 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, "all ones" }, |
| 228 | { 0x80000000u, 0x80000000u, 0x80000000u, "sign bit only" }, |
| 229 | { (uint32_t)-1, (uint32_t)-2, (uint32_t)-3, "negative lattice coords" }, |
| 230 | { (uint32_t)-7, 5u, (uint32_t)-11, "mixed sign lattice" }, |
| 231 | { 0xdeadbeefu, 0xcafebabeu, 0x12345678u, "the seed's own constant" }, |
| 232 | { 0xdeadbeefu + (3u << 2) + 13u, 0u, 0u, "the initialiser itself" }, |
| 233 | { bits_of(0.0f), bits_of(0.0f), bits_of(0.0f), "float bits: +0" }, |
| 234 | { bits_of(-0.0f), bits_of(0.0f), bits_of(0.0f), "float bits: -0 (differs from +0)" }, |
| 235 | { bits_of(0.5f), bits_of(1.5f), bits_of(2.5f), "float bits: exact halves" }, |
| 236 | { bits_of(1.0f), bits_of(-1.0f), bits_of(0.25f), "float bits: unit" }, |
| 237 | { bits_of(1e-7f), bits_of(1e7f), bits_of(3.14159265f), "float bits: wide range" }, |
| 238 | { 16u, 32u, 64u, "shift-sized words" }, |
| 239 | { 0x0000FFFFu, 0xFFFF0000u, 0x00FF00FFu, "half words" }, |
| 240 | }; |
| 241 | |
| 242 | // One shader: the subject's own text, then one wrapper per case. Baking |
| 243 | // the inputs in as literals keeps this to a single buffer -- there is |
| 244 | // nothing to get wrong in an upload that could be mistaken for a hash |
| 245 | // disagreeing. |
| 246 | std::string calls; |
| 247 | for (size_t i = 0; i < cases.size(); i++) { |
| 248 | char c[256]; |
| 249 | snprintf(c, sizeof(c), " out_v.v[%zu] = %s(%uu, %uu, %uu);\n", |
| 250 | i, SUBJECT_FN3, cases[i].x, cases[i].y, cases[i].z); |
| 251 | calls += c; |
| 252 | } |
| 253 | std::string src = |
| 254 | "#version 450\n" |
| 255 | "layout(local_size_x = 1) in;\n" |
| 256 | "layout(std430, binding = 0) buffer Out { uint v[]; } out_v;\n" + |
| 257 | lib + |
| 258 | "\nvoid main() {\n" + calls + "}\n"; |
| 259 | |
| 260 | Ctx c{}; |
| 261 | init_vulkan(c); |
| 262 | printf("device : %s\n", c.devname); |
| 263 | printf("subject: %s :: %s (%zu bytes of GLSL, extracted not retyped)\n", |
| 264 | file.c_str(), mark.c_str(), lib.size()); |
| 265 | printf("cases : %zu, compared EXACTLY\n\n", cases.size()); |
| 266 | |
| 267 | std::vector<uint32_t> spv = compile_glsl(src); |
| 268 | |
| 269 | VkBuffer buf; |
| 270 | VkDeviceMemory mem; |
| 271 | void *mapped = nullptr; |
| 272 | const VkDeviceSize bytes = sizeof(uint32_t) * cases.size(); |
| 273 | { |
| 274 | VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; |
| 275 | bi.size = bytes; |
| 276 | bi.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; |
| 277 | VK(vkCreateBuffer(c.dev, &bi, nullptr, &buf)); |
| 278 | VkMemoryRequirements mr; |
| 279 | vkGetBufferMemoryRequirements(c.dev, buf, &mr); |
| 280 | VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; |
| 281 | ai.allocationSize = mr.size; |
| 282 | ai.memoryTypeIndex = find_mem(c, mr.memoryTypeBits, |
| 283 | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); |
| 284 | VK(vkAllocateMemory(c.dev, &ai, nullptr, &mem)); |
| 285 | VK(vkBindBufferMemory(c.dev, buf, mem, 0)); |
| 286 | VK(vkMapMemory(c.dev, mem, 0, VK_WHOLE_SIZE, 0, &mapped)); |
| 287 | memset(mapped, 0, bytes); |
| 288 | } |
| 289 | |
| 290 | VkDescriptorSetLayoutBinding bind{}; |
| 291 | bind.binding = 0; |
| 292 | bind.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; |
| 293 | bind.descriptorCount = 1; |
| 294 | bind.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; |
| 295 | VkDescriptorSetLayoutCreateInfo dli{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; |
| 296 | dli.bindingCount = 1; dli.pBindings = &bind; |
| 297 | VkDescriptorSetLayout dsl; |
| 298 | VK(vkCreateDescriptorSetLayout(c.dev, &dli, nullptr, &dsl)); |
| 299 | |
| 300 | VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; |
| 301 | pli.setLayoutCount = 1; pli.pSetLayouts = &dsl; |
| 302 | VkPipelineLayout playout; |
| 303 | VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &playout)); |
| 304 | |
| 305 | VkShaderModuleCreateInfo smi{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; |
| 306 | smi.codeSize = spv.size() * 4; smi.pCode = spv.data(); |
| 307 | VkShaderModule mod; |
| 308 | VK(vkCreateShaderModule(c.dev, &smi, nullptr, &mod)); |
| 309 | |
| 310 | VkPipelineShaderStageCreateInfo st{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; |
| 311 | st.stage = VK_SHADER_STAGE_COMPUTE_BIT; st.module = mod; st.pName = "main"; |
| 312 | VkComputePipelineCreateInfo cpi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; |
| 313 | cpi.stage = st; cpi.layout = playout; |
| 314 | VkPipeline pipe; |
| 315 | VK(vkCreateComputePipelines(c.dev, VK_NULL_HANDLE, 1, &cpi, nullptr, &pipe)); |
| 316 | |
| 317 | VkDescriptorPoolSize ps{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1}; |
| 318 | VkDescriptorPoolCreateInfo dpi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; |
| 319 | dpi.maxSets = 1; dpi.poolSizeCount = 1; dpi.pPoolSizes = &ps; |
| 320 | VkDescriptorPool dpool; |
| 321 | VK(vkCreateDescriptorPool(c.dev, &dpi, nullptr, &dpool)); |
| 322 | VkDescriptorSetAllocateInfo dai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; |
| 323 | dai.descriptorPool = dpool; dai.descriptorSetCount = 1; dai.pSetLayouts = &dsl; |
| 324 | VkDescriptorSet dset; |
| 325 | VK(vkAllocateDescriptorSets(c.dev, &dai, &dset)); |
| 326 | VkDescriptorBufferInfo dbi{buf, 0, VK_WHOLE_SIZE}; |
| 327 | VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; |
| 328 | w.dstSet = dset; w.dstBinding = 0; w.descriptorCount = 1; |
| 329 | w.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; w.pBufferInfo = &dbi; |
| 330 | vkUpdateDescriptorSets(c.dev, 1, &w, 0, nullptr); |
| 331 | |
| 332 | VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; |
| 333 | cbi.commandPool = c.cmdpool; cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbi.commandBufferCount = 1; |
| 334 | VkCommandBuffer cmd; |
| 335 | VK(vkAllocateCommandBuffers(c.dev, &cbi, &cmd)); |
| 336 | VkCommandBufferBeginInfo bbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; |
| 337 | bbi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; |
| 338 | VK(vkBeginCommandBuffer(cmd, &bbi)); |
| 339 | vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipe); |
| 340 | vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, playout, 0, 1, &dset, 0, nullptr); |
| 341 | vkCmdDispatch(cmd, 1, 1, 1); |
| 342 | VK(vkEndCommandBuffer(cmd)); |
| 343 | VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO}; |
| 344 | si.commandBufferCount = 1; si.pCommandBuffers = &cmd; |
| 345 | VK(vkQueueSubmit(c.queue, 1, &si, VK_NULL_HANDLE)); |
| 346 | VK(vkQueueWaitIdle(c.queue)); |
| 347 | |
| 348 | const uint32_t *got = (const uint32_t *)mapped; |
| 349 | int bad = 0; |
| 350 | for (size_t i = 0; i < cases.size(); i++) { |
| 351 | const uint32_t want = godot::bnoise::hash(cases[i].x, cases[i].y, cases[i].z); |
| 352 | if (got[i] != want) { |
| 353 | bad++; |
| 354 | printf(" FAIL hash(%10u, %10u, %10u) cpu=%10u gpu=%10u [%s]\n", |
| 355 | cases[i].x, cases[i].y, cases[i].z, want, got[i], cases[i].why); |
| 356 | } |
| 357 | } |
| 358 | printf("\n%zu/%zu cases agree exactly\n", cases.size() - (size_t)bad, cases.size()); |
| 359 | return bad ? 1 : 0; |
| 360 | } |